From 5b948e2161b08b13d32bdbb480b26c8fa44d42f7 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Fri, 29 Aug 2025 05:07:58 +0300 Subject: [PATCH 001/325] fix(web)!: replace current clipboard logic with auto and manual clipboard modes (#935) Adds: - auto clipboard mode. When it's enabled, the clipboard will be automatically monitored for changes, and updates from the server will be automatically saved to the local clipboard (this is the old logic; nothing has changed). - manual clipboard mode. One calls dedicated functions to interact with the clipboard. --- crates/iron-remote-desktop/src/lib.rs | 10 - crates/iron-remote-desktop/src/session.rs | 3 - crates/ironrdp-cliprdr/src/backend.rs | 12 - crates/ironrdp-cliprdr/src/lib.rs | 2 - crates/ironrdp-web/src/clipboard.rs | 11 - crates/ironrdp-web/src/session.rs | 11 - .../src/enums/SessionEventType.ts | 3 + .../src/interfaces/UserInteraction.ts | 6 + .../src/iron-remote-desktop.svelte | 441 +----------------- .../src/lib/stores/componentLifecycleStore.ts | 3 + .../src/lib/stores/runWhenFocusedStore.ts | 29 ++ .../src/services/PublicAPI.ts | 20 +- .../src/services/clipboard.service.ts | 312 +++++++++++++ .../src/services/remote-desktop.service.ts | 26 +- 14 files changed, 405 insertions(+), 484 deletions(-) create mode 100644 web-client/iron-remote-desktop/src/lib/stores/componentLifecycleStore.ts create mode 100644 web-client/iron-remote-desktop/src/lib/stores/runWhenFocusedStore.ts create mode 100644 web-client/iron-remote-desktop/src/services/clipboard.service.ts diff --git a/crates/iron-remote-desktop/src/lib.rs b/crates/iron-remote-desktop/src/lib.rs index b0053faf7a..6383f64218 100644 --- a/crates/iron-remote-desktop/src/lib.rs +++ b/crates/iron-remote-desktop/src/lib.rs @@ -267,16 +267,6 @@ macro_rules! make_bridge { )) } - #[wasm_bindgen(js_name = remoteReceivedFormatListCallback)] - pub fn remote_received_format_list_callback( - &self, - callback: $crate::internal::web_sys::js_sys::Function, - ) -> Self { - Self($crate::SessionBuilder::remote_received_format_list_callback( - &self.0, callback, - )) - } - #[wasm_bindgen(js_name = forceClipboardUpdateCallback)] pub fn force_clipboard_update_callback( &self, diff --git a/crates/iron-remote-desktop/src/session.rs b/crates/iron-remote-desktop/src/session.rs index c618b05d4f..afe9f2d52b 100644 --- a/crates/iron-remote-desktop/src/session.rs +++ b/crates/iron-remote-desktop/src/session.rs @@ -45,9 +45,6 @@ pub trait SessionBuilder { #[must_use] fn remote_clipboard_changed_callback(&self, callback: js_sys::Function) -> Self; - #[must_use] - fn remote_received_format_list_callback(&self, callback: js_sys::Function) -> Self; - #[must_use] fn force_clipboard_update_callback(&self, callback: js_sys::Function) -> Self; diff --git a/crates/ironrdp-cliprdr/src/backend.rs b/crates/ironrdp-cliprdr/src/backend.rs index 2caa11b56c..7d2bd59c30 100644 --- a/crates/ironrdp-cliprdr/src/backend.rs +++ b/crates/ironrdp-cliprdr/src/backend.rs @@ -73,18 +73,6 @@ pub trait CliprdrBackend: AsAny + core::fmt::Debug + Send { /// client's clipboard prior to `CLIPRDR` SVC initialization. fn on_request_format_list(&mut self); - /// Called by [crate::Cliprdr] when copy sequence is finished. - /// This method is called after remote returns format list response. - /// - /// Useful for the backend implementations which need to know when remote is ready to paste - /// previously advertised formats from the client. E.g. Web client uses this for - /// Firefox-specific logic to delay sending keyboard key events to prevent pasting the old - /// data from the clipboard. - /// - /// This method has default implementation which does nothing because it is not required for - /// most of the backends. - fn on_format_list_received(&mut self) {} - /// Adjusts [crate::Cliprdr] backend capabilities based on capabilities negotiated with a server. /// /// Called by [crate::Cliprdr] when capability negotiation is finished and server capabilities are diff --git a/crates/ironrdp-cliprdr/src/lib.rs b/crates/ironrdp-cliprdr/src/lib.rs index 5205ee5ca3..fa83f1abcf 100644 --- a/crates/ironrdp-cliprdr/src/lib.rs +++ b/crates/ironrdp-cliprdr/src/lib.rs @@ -166,8 +166,6 @@ impl Cliprdr { info!("CLIPRDR(clipboard) Remote has received format list successfully"); } } - - self.backend.on_format_list_received(); } FormatListResponse::Fail => { return self.handle_error_transition(ClipboardError::FormatListRejected); diff --git a/crates/ironrdp-web/src/clipboard.rs b/crates/ironrdp-web/src/clipboard.rs index eb5d1c5ea7..3f2e91e550 100644 --- a/crates/ironrdp-web/src/clipboard.rs +++ b/crates/ironrdp-web/src/clipboard.rs @@ -105,7 +105,6 @@ pub(crate) enum WasmClipboardBackendMessage { RemoteClipboardChanged(Vec), RemoteDataResponse(FormatDataResponse<'static>), - FormatListReceived, ForceClipboardUpdate, } @@ -125,7 +124,6 @@ pub(crate) struct WasmClipboard { /// Callbacks, required to interact with JS code from within the backend. pub(crate) struct JsClipboardCallbacks { pub(crate) on_remote_clipboard_changed: js_sys::Function, - pub(crate) on_remote_received_format_list: Option, pub(crate) on_force_clipboard_update: Option, } @@ -496,11 +494,6 @@ impl WasmClipboard { } } } - WasmClipboardBackendMessage::FormatListReceived => { - if let Some(callback) = self.js_callbacks.on_remote_received_format_list.as_mut() { - callback.call0(&JsValue::NULL).expect("failed to call JS callback"); - } - } WasmClipboardBackendMessage::ForceClipboardUpdate => { if let Some(callback) = self.js_callbacks.on_force_clipboard_update.as_mut() { callback.call0(&JsValue::NULL).expect("failed to call JS callback"); @@ -548,10 +541,6 @@ impl CliprdrBackend for WasmClipboardBackend { self.send_event(WasmClipboardBackendMessage::ForceClipboardUpdate); } - fn on_format_list_received(&mut self) { - self.send_event(WasmClipboardBackendMessage::FormatListReceived); - } - fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) { // No additional capabilities yet } diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index 92a50fc77f..a29ee2e1b5 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -65,7 +65,6 @@ struct SessionBuilderInner { set_cursor_style_callback: Option, set_cursor_style_callback_context: Option, remote_clipboard_changed_callback: Option, - remote_received_format_list_callback: Option, force_clipboard_update_callback: Option, use_display_control: bool, @@ -94,7 +93,6 @@ impl Default for SessionBuilderInner { set_cursor_style_callback: None, set_cursor_style_callback_context: None, remote_clipboard_changed_callback: None, - remote_received_format_list_callback: None, force_clipboard_update_callback: None, use_display_control: false, @@ -198,12 +196,6 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { self.clone() } - /// Optional - fn remote_received_format_list_callback(&self, callback: js_sys::Function) -> Self { - self.0.borrow_mut().remote_received_format_list_callback = Some(callback); - self.clone() - } - /// Optional fn force_clipboard_update_callback(&self, callback: js_sys::Function) -> Self { self.0.borrow_mut().force_clipboard_update_callback = Some(callback); @@ -253,7 +245,6 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { set_cursor_style_callback, set_cursor_style_callback_context, remote_clipboard_changed_callback, - remote_received_format_list_callback, force_clipboard_update_callback, outbound_message_size_limit, ); @@ -283,7 +274,6 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { .clone() .context("set_cursor_style_callback_context missing")?; remote_clipboard_changed_callback = inner.remote_clipboard_changed_callback.clone(); - remote_received_format_list_callback = inner.remote_received_format_list_callback.clone(); force_clipboard_update_callback = inner.force_clipboard_update_callback.clone(); outbound_message_size_limit = inner.outbound_message_size_limit; } @@ -302,7 +292,6 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { clipboard::WasmClipboardMessageProxy::new(input_events_tx.clone()), clipboard::JsClipboardCallbacks { on_remote_clipboard_changed: callback, - on_remote_received_format_list: remote_received_format_list_callback, on_force_clipboard_update: force_clipboard_update_callback, }, ) diff --git a/web-client/iron-remote-desktop/src/enums/SessionEventType.ts b/web-client/iron-remote-desktop/src/enums/SessionEventType.ts index 07418e2aa4..1a351001d6 100644 --- a/web-client/iron-remote-desktop/src/enums/SessionEventType.ts +++ b/web-client/iron-remote-desktop/src/enums/SessionEventType.ts @@ -2,4 +2,7 @@ STARTED, TERMINATED, ERROR, + + // Clipboard events + CLIPBOARD_REMOTE_UPDATE, } diff --git a/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts b/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts index 19c1e25172..fd1c4ec022 100644 --- a/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts +++ b/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts @@ -31,5 +31,11 @@ export interface UserInteraction { setEnableClipboard(enable: boolean): void; + setEnableAutoClipboard(enable: boolean): void; + + saveRemoteClipboardData(): Promise; + + sendClipboardData(): Promise; + invokeExtension(ext: Extension): void; } diff --git a/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte b/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte index 3db73e4d88..ac0ada0118 100644 --- a/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte +++ b/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte @@ -20,8 +20,10 @@ import type { ResizeEvent } from './interfaces/ResizeEvent'; import { PublicAPI } from './services/PublicAPI'; import { ScreenScale } from './enums/ScreenScale'; - import type { ClipboardData } from './interfaces/ClipboardData'; import type { RemoteDesktopModule } from './interfaces/RemoteDesktopModule'; + import { isComponentDestroyed } from './lib/stores/componentLifecycleStore'; + import { runWhenFocusedQueue } from './lib/stores/runWhenFocusedStore'; + import { ClipboardService } from './services/clipboard.service'; let { scale, @@ -46,410 +48,22 @@ let inner: HTMLDivElement; let wrapper: HTMLDivElement; - let screenViewer: HTMLDivElement; let canvas: HTMLCanvasElement; let viewerStyle = $state(''); let wrapperStyle = $state(''); let remoteDesktopService = new RemoteDesktopService(module); - let publicAPI = new PublicAPI(remoteDesktopService); + let clipboardService = new ClipboardService(remoteDesktopService, module); + let publicAPI = new PublicAPI(remoteDesktopService, clipboardService); let currentScreenScale = ScreenScale.Fit; - // Firefox's clipboard API is very limited, and doesn't support reading from the clipboard - // without changing browser settings via `about:config`. - // - // For firefox, we will use a different approach by marking `screen-wrapper` component - // as `contenteditable=true`, and then using the `onpaste`/`oncopy`/`oncut` events. - let isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; - - const CLIPBOARD_MONITORING_INTERVAL = 100; // ms - - let isClipboardApiSupported = false; - let lastClientClipboardItems: Record = {}; - let lastReceivedClipboardData: Record = {}; - let lastSentClipboardData: ClipboardData | null = null; - let lastClipboardMonitorLoopError: Error | null = null; - - let componentDestroyed = false; - let runWhenFocusedQueue: (() => void)[] = []; - - /* Firefox-specific BEGIN */ - - // See `ffRemoteClipboardData` variable docs below - const FF_REMOTE_CLIPBOARD_DATA_SET_RETRY_INTERVAL = 100; // ms - const FF_REMOTE_CLIPBOARD_DATA_SET_MAX_RETRIES = 30; // 3 seconds (100ms * 30) - // On Firefox, this interval is used to stop delaying the keyboard events if the paste event has - // failed and we haven't received any clipboard data from the remote side. - const FF_LOCAL_CLIPBOARD_COPY_TIMEOUT = 1000; // 1s (For text-only data this should be enough) - - // In Firefox, we need this variable due to fact that `clipboard.writeText()` should only be - // called in scope of user-initiated event processing (e.g. keyboard event), but we receive - // clipboard data from the remote side asynchronously in wasm service callback. therefore we - // set this variable in callback and use its value on the user-initiated copy event. - let ffRemoteClipboardData: ClipboardData | null = null; - // For Firefox we need this variable to perform wait loop for the remote side to finish sending - // clipboard content to the client. - let ffRemoteClipboardDataRetriesLeft = 0; - let ffPostponeKeyboardEvents = false; - let ffDelayedKeyboardEvents: KeyboardEvent[] = []; - let ffCnavasFocused = false; - - /* Firefox-specific END */ - - /* Clipboard initialization BEGIN */ - function initClipboard() { - // Detect if browser supports async Clipboard API - if (!isFirefox && navigator.clipboard != undefined) { - if (navigator.clipboard.read != undefined && navigator.clipboard.write != undefined) { - isClipboardApiSupported = true; - } - } - - if (isFirefox) { - remoteDesktopService.setOnRemoteClipboardChanged(ffOnRemoteClipboardChanged); - remoteDesktopService.setOnRemoteReceivedFormatList(ffOnRemoteReceivedFormatList); - remoteDesktopService.setOnForceClipboardUpdate(onForceClipboardUpdate); - } else if (isClipboardApiSupported) { - remoteDesktopService.setOnRemoteClipboardChanged(onRemoteClipboardChanged); - remoteDesktopService.setOnForceClipboardUpdate(onForceClipboardUpdate); - - // Start the clipboard monitoring loop - setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); - } - } - - /* Clipboard initialization END */ - - function isCopyKeyboardEvent(evt: KeyboardEvent) { - return ( - (evt.ctrlKey && evt.code === 'KeyC') || - (evt.ctrlKey && evt.code === 'KeyX') || - evt.code == 'Copy' || - evt.code == 'Cut' - ); - } - - function isPasteKeyboardEvent(evt: KeyboardEvent) { - return (evt.ctrlKey && evt.code === 'KeyV') || evt.code == 'Paste'; - } - - // This function is required to convert `ClipboardData` to an object that can be used - // with `ClipboardItem` API. - function clipboardDataToRecord(data: ClipboardData): Record { - let result = {} as Record; - - for (const item of data.items()) { - let mime = item.mimeType(); - let value = new Blob([item.value()], { type: mime }); - - result[mime] = value; - } - - return result; - } - - function clipboardDataToClipboardItemsRecord(data: ClipboardData): Record { - let result = {} as Record; - - for (const item of data.items()) { - let mime = item.mimeType(); - result[mime] = item.value(); - } - - return result; - } - - // This callback is required to send initial clipboard state if available. - function onForceClipboardUpdate() { - // TODO(Fix): lastSentClipboardData is nullptr. - try { - if (lastSentClipboardData) { - remoteDesktopService.onClipboardChanged(lastSentClipboardData); - } else { - remoteDesktopService.onClipboardChangedEmpty(); - } - } catch (err) { - console.error('Failed to send initial clipboard state: ' + err); - } - } - - function runWhenWindowFocused(fn: () => void) { - if (document.hasFocus()) { - fn(); - } else { - runWhenFocusedQueue.push(fn); - } - } - - // This callback is required to update client clipboard state when remote side has changed. - function onRemoteClipboardChanged(data: ClipboardData) { - try { - const mime_formats = clipboardDataToRecord(data); - const clipboard_item = new ClipboardItem(mime_formats); - runWhenWindowFocused(() => { - lastReceivedClipboardData = clipboardDataToClipboardItemsRecord(data); - navigator.clipboard.write([clipboard_item]); - }); - } catch (err) { - console.error('Failed to set client clipboard: ' + err); - } - } - - // Called periodically to monitor clipboard changes - async function onMonitorClipboard() { - try { - if (!document.hasFocus()) { - return; - } - - var value = await navigator.clipboard.read(); - - // Clipboard is empty - if (value.length == 0) { - return; - } - - // We only support one item at a time - var item = value[0]; - - if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { - // Unsupported types - return; - } - - var values: Record = {}; - var sameValue = true; - - // Sadly, browsers build new `ClipboardItem` object for each `read` call, - // so we can't do reference comparison here :( - // - // For monitoring loop approach we also can't drop this logic, as it will result in - // very frequent network activity. - for (const kind of item.types) { - // Get blob - const blobIsString = kind.startsWith('text/'); - - const blob = await item.getType(kind); - const value = blobIsString ? await blob.text() : new Uint8Array(await blob.arrayBuffer()); - - const is_equal = blobIsString - ? function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { - return a === b; - } - : function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { - if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) { - return false; - } - - return ( - a != undefined && b != undefined && a.length === b.length && a.every((v, i) => v === b[i]) - ); - }; - - const previousValue = lastClientClipboardItems[kind]; - - if (!is_equal(previousValue, value)) { - // When the local clipboard updates, we need to compare it with the last data received from the server. - // If it's identical, the clipboard was updated with the server's data, so we shouldn't send this data - // to the server. - if (is_equal(lastReceivedClipboardData[kind], value)) { - lastClientClipboardItems[kind] = lastReceivedClipboardData[kind]; - } - // One of mime types has changed, we need to update the clipboard cache - else { - sameValue = false; - } - } - - values[kind] = value; - } - - // Clipboard has changed, we need to acknowledge remote side about it. - if (!sameValue) { - lastClientClipboardItems = values; - - let clipboardData = new module.ClipboardData(); - - // Iterate over `Record` type - Object.entries(values).forEach(([key, value]: [string, string | Uint8Array]) => { - // skip null/undefined values - if (value == null || value == undefined) { - return; - } - - if (key.startsWith('text/') && typeof value === 'string') { - clipboardData.addText(key, value); - } else if (key.startsWith('image/') && value instanceof Uint8Array) { - clipboardData.addBinary(key, value); - } - }); - - if (!clipboardData.isEmpty()) { - lastSentClipboardData = clipboardData; - // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. - await remoteDesktopService.onClipboardChanged(clipboardData); - } - } - } catch (err) { - if (err instanceof Error) { - const printError = - lastClipboardMonitorLoopError === null || - lastClipboardMonitorLoopError.toString() !== err.toString(); - // Prevent spamming the console with the same error - if (printError) { - console.error('Clipboard monitoring error: ' + err); - } - lastClipboardMonitorLoopError = err; - } - } finally { - if (!componentDestroyed) { - setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); - } - } - } - - /* Firefox-specific BEGIN */ - - function ffOnRemoteReceivedFormatList() { - try { - // We are ready to send delayed Ctrl+V events - ffSimulateDelayedKeyEvents(); - } catch (err) { - console.error('Failed to send delayed keyboard events: ' + err); - } - } - - // Only set variable on callback, the real clipboard update will be performed in keyboard - // callback. (User-initiated event is required for Firefox to allow clipboard write) - function ffOnRemoteClipboardChanged(data: ClipboardData) { - ffRemoteClipboardData = data; - } - - function ffWaitForRemoteClipboardDataSet() { - if (ffRemoteClipboardData) { - try { - let clipboard_data = ffRemoteClipboardData; - ffRemoteClipboardData = null; - for (const item of clipboard_data.items()) { - // Firefox only supports text/plain mime type for clipboard writes :( - if (item.mimeType() === 'text/plain') { - const value = item.value(); - - if (typeof value === 'string') { - navigator.clipboard.writeText(value); - } else { - loggingService.error('Unexpected value for text/plain clipboard item'); - } - - break; - } - } - } catch (err) { - console.error('Failed to set client clipboard: ' + err); - } - } else if (ffRemoteClipboardDataRetriesLeft > 0) { - ffRemoteClipboardDataRetriesLeft--; - setTimeout(ffWaitForRemoteClipboardDataSet, FF_REMOTE_CLIPBOARD_DATA_SET_RETRY_INTERVAL); - } - } - - function ffSimulateDelayedKeyEvents() { - if (ffDelayedKeyboardEvents.length > 0) { - for (const evt of ffDelayedKeyboardEvents) { - // simulate consecutive key events - keyboardEvent(evt); - } - ffDelayedKeyboardEvents = []; - } - ffPostponeKeyboardEvents = false; - } - - function ffOnPasteHandler(evt: ClipboardEvent) { - // We don't actually want to paste the clipboard data into the `contenteditable` div. - evt.preventDefault(); - - // `onpaste` events are handled only for Firefox, other browsers we use the clipboard API - // for reading the clipboard. - if (!isFirefox) { - // Prevent processing of the paste event by the browser. - return; - } - - try { - let clipboardData = new module.ClipboardData(); - - if (evt.clipboardData == null) { - return; - } - - for (var clipItem of evt.clipboardData.items) { - let mime = clipItem.type; - - if (mime.startsWith('text/')) { - clipItem.getAsString((str: string) => { - clipboardData.addText(mime, str); - - if (!clipboardData.isEmpty()) { - remoteDesktopService.onClipboardChanged(clipboardData); - } - }); - break; - } - - if (mime.startsWith('image/')) { - let file = clipItem.getAsFile(); - if (file == null) { - continue; - } - - file.arrayBuffer().then((buffer: ArrayBuffer) => { - const strict_buffer = new Uint8Array(buffer); - - clipboardData.addBinary(mime, strict_buffer); - - if (!clipboardData.isEmpty()) { - remoteDesktopService.onClipboardChanged(clipboardData); - } - }); - break; - } - } - } catch (err) { - console.error('Failed to update remote clipboard: ' + err); - } - } - - /* Firefox-specific END */ - function initListeners() { serverBridgeListeners(); userInteractionListeners(); function captureKeys(evt: KeyboardEvent) { if (capturingInputs()) { - if (ffPostponeKeyboardEvents) { - evt.preventDefault(); - ffDelayedKeyboardEvents.push(evt); - return; - } - - // For Firefox we need to make `onpaste` event still fire even if - // keyboard is being captured. Not capturing `Ctrl + V` should not create any - // side effects, therefore is safe to skip capture for it. - let isFirefoxPaste = isFirefox && isPasteKeyboardEvent(evt); - - if (isFirefoxPaste) { - ffPostponeKeyboardEvents = true; - ffDelayedKeyboardEvents = []; - ffDelayedKeyboardEvents.push(evt); - - // If during the given timeout we weren't able to finish the copy sequence, we need to - // simulate all queued keyboard events. - setTimeout(ffSimulateDelayedKeyEvents, FF_LOCAL_CLIPBOARD_COPY_TIMEOUT); - return; - } - keyboardEvent(evt); } } @@ -645,22 +259,6 @@ } function setMouseButtonState(state: MouseEvent, isDown: boolean) { - if (isFirefox) { - if (isDown && state.button == 0 && !ffCnavasFocused) { - // Do not capture first mouse down event on Firefox, as we need to transfer focus to the - // canvas first in order to receive paste events. - // wasmService.mouseButtonState(state, isDown, false); - // Focus `contenteditable` element to receive `on_paste` events - canvas.focus(); - // Finish the focus sequence on Firefox - ffCnavasFocused = true; - } else { - // This is needed to prevent visible "double click" selection on - // `texteditable` element - screenViewer.blur(); - } - } - remoteDesktopService.mouseButtonState(state, isDown, true); } @@ -678,18 +276,6 @@ } function keyboardEvent(evt: KeyboardEvent) { - const browserHasClipboardAccess = - navigator.clipboard != undefined && navigator.clipboard.writeText != undefined; - - if (isFirefox && browserHasClipboardAccess && isCopyKeyboardEvent(evt)) { - // Special processing for firefox, as the only way Firefox supports clipboard write is - // only after some user-initiated event (e.g. keyboard event). - // therefore we need to wait here for the clipboard data to be ready. - - ffRemoteClipboardDataRetriesLeft = FF_REMOTE_CLIPBOARD_DATA_SET_MAX_RETRIES; - ffWaitForRemoteClipboardDataSet(); - } - remoteDesktopService.sendKeyboardEvent(evt); // Propagate further @@ -731,23 +317,28 @@ } function focusEventHandler() { - while (runWhenFocusedQueue.length > 0) { - const fn = runWhenFocusedQueue.shift(); - fn?.(); + try { + while (runWhenFocusedQueue.length() > 0) { + const fn = runWhenFocusedQueue.shift(); + fn?.(); + } + } catch (err) { + console.error('Failed to run the function queued for execution when the window received focus: ' + err); } } onMount(async () => { + isComponentDestroyed.set(false); loggingService.verbose = verbose === 'true'; loggingService.info('Dom ready'); await initcanvas(); - initClipboard(); + clipboardService.initClipboard(); }); onDestroy(() => { window.removeEventListener('resize', resizeHandler); window.removeEventListener('focus', focusEventHandler); - componentDestroyed = true; + isComponentDestroyed.set(true); }); @@ -759,7 +350,7 @@ class:capturing-inputs={capturingInputs} style={wrapperStyle} > -
+
() { + const store = writable([]); + + return { + subscribe: store.subscribe, + + enqueue(item: T) { + store.update((queue) => [...queue, item]); + }, + + shift(): T | undefined { + let first: T | undefined; + store.update((queue) => { + if (queue.length == 0) return queue; + first = queue[0]; + return queue.slice(1); + }); + return first; + }, + + length(): number { + return get(store).length; + }, + }; +} + +export const runWhenFocusedQueue = createQueueStore<() => void>(); diff --git a/web-client/iron-remote-desktop/src/services/PublicAPI.ts b/web-client/iron-remote-desktop/src/services/PublicAPI.ts index bea1058455..c425caf24d 100644 --- a/web-client/iron-remote-desktop/src/services/PublicAPI.ts +++ b/web-client/iron-remote-desktop/src/services/PublicAPI.ts @@ -7,12 +7,15 @@ import type { ScreenScale } from '../enums/ScreenScale'; import { ConfigBuilder } from './ConfigBuilder'; import { Config } from './Config'; import type { Extension } from '../interfaces/Extension'; +import type { ClipboardService } from './clipboard.service'; export class PublicAPI { private remoteDesktopService: RemoteDesktopService; + private clipboardService: ClipboardService; - constructor(remoteDesktopService: RemoteDesktopService) { + constructor(remoteDesktopService: RemoteDesktopService, clipboardService: ClipboardService) { this.remoteDesktopService = remoteDesktopService; + this.clipboardService = clipboardService; } private configBuilder(): ConfigBuilder { @@ -61,6 +64,18 @@ export class PublicAPI { this.remoteDesktopService.setEnableClipboard(enable); } + private setEnableAutoClipboard(enable: boolean) { + this.remoteDesktopService.setEnableAutoClipboard(enable); + } + + private async saveRemoteClipboardData(): Promise { + return await this.clipboardService.saveRemoteClipboardData(); + } + + private async sendClipboardData(): Promise { + return await this.clipboardService.sendClipboardData(); + } + private invokeExtension(ext: Extension) { this.remoteDesktopService.invokeExtension(ext); } @@ -81,6 +96,9 @@ export class PublicAPI { setCursorStyleOverride: this.setCursorStyleOverride.bind(this), resize: this.resize.bind(this), setEnableClipboard: this.setEnableClipboard.bind(this), + setEnableAutoClipboard: this.setEnableAutoClipboard.bind(this), + saveRemoteClipboardData: this.saveRemoteClipboardData.bind(this), + sendClipboardData: this.sendClipboardData.bind(this), invokeExtension: this.invokeExtension.bind(this), }; } diff --git a/web-client/iron-remote-desktop/src/services/clipboard.service.ts b/web-client/iron-remote-desktop/src/services/clipboard.service.ts new file mode 100644 index 0000000000..88e155806f --- /dev/null +++ b/web-client/iron-remote-desktop/src/services/clipboard.service.ts @@ -0,0 +1,312 @@ +import type { RemoteDesktopService } from './remote-desktop.service'; +import { isComponentDestroyed } from '../lib/stores/componentLifecycleStore'; +import { get } from 'svelte/store'; +import type { ClipboardData } from '../interfaces/ClipboardData'; +import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; +import { runWhenFocusedQueue } from '../lib/stores/runWhenFocusedStore'; +import { SessionEventType } from '../enums/SessionEventType'; + +const CLIPBOARD_MONITORING_INTERVAL = 100; // ms + +export class ClipboardService { + private remoteDesktopService: RemoteDesktopService; + private module: RemoteDesktopModule; + + private isClipboardApiSupported: boolean = false; + + private lastClientClipboardItems: Record = {}; + private lastReceivedClipboardData: Record = {}; + private lastSentClipboardData: ClipboardData | null = null; + private clipboardDataToSave: ClipboardData | null = null; + private lastClipboardMonitorLoopError: Error | null = null; + + constructor(remoteDesktopService: RemoteDesktopService, module: RemoteDesktopModule) { + this.remoteDesktopService = remoteDesktopService; + this.module = module; + } + + initClipboard() { + // Detect if browser supports async Clipboard API + if (navigator.clipboard != undefined) { + if (navigator.clipboard.read != undefined && navigator.clipboard.write != undefined) { + this.isClipboardApiSupported = true; + } + } + + if (!this.isClipboardApiSupported) return; + + this.remoteDesktopService.setOnForceClipboardUpdate(this.onForceClipboardUpdate.bind(this)); + + if (this.remoteDesktopService.autoClipboard) { + this.remoteDesktopService.setOnRemoteClipboardChanged(this.onRemoteClipboardChangedAutoMode.bind(this)); + // Start the clipboard monitoring loop + setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL); + } else { + this.remoteDesktopService.setOnRemoteClipboardChanged(this.onRemoteClipboardChangedManualMode.bind(this)); + } + } + + // Copies clipboard content received from the server to the local clipboard. + // Returns the result of the operation. On failure, it additionally raises an error session event. + async saveRemoteClipboardData(): Promise { + if (this.clipboardDataToSave == null) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'The server did not send the clipboard data.', + }); + return false; + } + + try { + const mime_formats = this.clipboardDataToRecord(this.clipboardDataToSave); + const clipboard_item = new ClipboardItem(mime_formats); + await navigator.clipboard.write([clipboard_item]); + + this.clipboardDataToSave = null; + return true; + } catch (err) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'Failed to write to the clipboard: ' + err, + }); + return false; + } + } + + // Sends local clipboard's content to the server. + // Returns the result of the operation. On failure, it additionally raises an error session event. + async sendClipboardData(): Promise { + try { + const value = await navigator.clipboard.read(); + + // Clipboard is empty + if (value.length == 0) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'The clipboard has no data.', + }); + return false; + } + + // We only support one item at a time + const item = value[0]; + + if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { + // Unsupported types + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'The clipboard has no data of supported type (text or image).', + }); + return false; + } + + const clipboardData = new this.module.ClipboardData(); + + for (const kind of item.types) { + // Get blob + const blobIsString = kind.startsWith('text/'); + const blob = await item.getType(kind); + + if (blobIsString) { + clipboardData.addText(kind, await blob.text()); + } else { + clipboardData.addBinary(kind, new Uint8Array(await blob.arrayBuffer())); + } + } + + if (!clipboardData.isEmpty()) { + this.lastSentClipboardData = clipboardData; + // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. + await this.remoteDesktopService.onClipboardChanged(clipboardData); + } + + return true; + } catch (err) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'Failed to read from the clipboard: ' + err, + }); + return false; + } + } + + private runWhenWindowFocused(fn: () => void) { + if (document.hasFocus()) { + fn(); + } else { + runWhenFocusedQueue.enqueue(fn); + } + } + + // This function is required to convert `ClipboardData` to an object that can be used + // with `ClipboardItem` API. + private clipboardDataToRecord(data: ClipboardData): Record { + const result = {} as Record; + + for (const item of data.items()) { + const mime = item.mimeType(); + result[mime] = new Blob([item.value()], { type: mime }); + } + + return result; + } + + private clipboardDataToClipboardItemsRecord(data: ClipboardData): Record { + const result = {} as Record; + + for (const item of data.items()) { + const mime = item.mimeType(); + result[mime] = item.value(); + } + + return result; + } + + // This callback is required to send initial clipboard state if available. + private onForceClipboardUpdate() { + // TODO(Fix): lastSentClipboardData is nullptr. + try { + if (this.lastSentClipboardData) { + this.remoteDesktopService.onClipboardChanged(this.lastSentClipboardData); + } else { + this.remoteDesktopService.onClipboardChangedEmpty(); + } + } catch (err) { + console.error('Failed to send initial clipboard state: ' + err); + } + } + + // This callback is required to update client clipboard state when remote side has changed. + private onRemoteClipboardChangedManualMode(data: ClipboardData) { + this.clipboardDataToSave = data; + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.CLIPBOARD_REMOTE_UPDATE, + data: '', + }); + } + + // This callback is required to update client clipboard state when remote side has changed. + private onRemoteClipboardChangedAutoMode(data: ClipboardData) { + try { + const mime_formats = this.clipboardDataToRecord(data); + const clipboard_item = new ClipboardItem(mime_formats); + this.runWhenWindowFocused(() => { + this.lastReceivedClipboardData = this.clipboardDataToClipboardItemsRecord(data); + navigator.clipboard.write([clipboard_item]); + }); + } catch (err) { + console.error('Failed to set client clipboard: ' + err); + } + } + + // Called periodically to monitor clipboard changes + private async onMonitorClipboard() { + try { + if (!document.hasFocus()) { + return; + } + + const value = await navigator.clipboard.read(); + + // Clipboard is empty + if (value.length == 0) { + return; + } + + // We only support one item at a time + const item = value[0]; + + if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { + // Unsupported types + return; + } + + const values: Record = {}; + let sameValue = true; + + // Sadly, browsers build new `ClipboardItem` object for each `read` call, + // so we can't do reference comparison here :( + // + // For monitoring loop approach we also can't drop this logic, as it will result in + // very frequent network activity. + for (const kind of item.types) { + // Get blob + const blobIsString = kind.startsWith('text/'); + + const blob = await item.getType(kind); + const value = blobIsString ? await blob.text() : new Uint8Array(await blob.arrayBuffer()); + + const is_equal = blobIsString + ? function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { + return a === b; + } + : function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { + if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) { + return false; + } + + return a.length === b.length && a.every((v, i) => v === b[i]); + }; + + const previousValue = this.lastClientClipboardItems[kind]; + + if (!is_equal(previousValue, value)) { + // When the local clipboard updates, we need to compare it with the last data received from the server. + // If it's identical, the clipboard was updated with the server's data, so we shouldn't send this data + // to the server. + if (is_equal(this.lastReceivedClipboardData[kind], value)) { + this.lastClientClipboardItems[kind] = this.lastReceivedClipboardData[kind]; + } + // One of mime types has changed, we need to update the clipboard cache + else { + sameValue = false; + } + } + + values[kind] = value; + } + + // Clipboard has changed, we need to acknowledge remote side about it. + if (!sameValue) { + this.lastClientClipboardItems = values; + + const clipboardData = new this.module.ClipboardData(); + + // Iterate over `Record` type + Object.entries(values).forEach(([key, value]: [string, string | Uint8Array]) => { + // skip null/undefined values + if (value == null) { + return; + } + + if (key.startsWith('text/') && typeof value === 'string') { + clipboardData.addText(key, value); + } else if (key.startsWith('image/') && value instanceof Uint8Array) { + clipboardData.addBinary(key, value); + } + }); + + if (!clipboardData.isEmpty()) { + this.lastSentClipboardData = clipboardData; + // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. + await this.remoteDesktopService.onClipboardChanged(clipboardData); + } + } + } catch (err) { + if (err instanceof Error) { + const printError = + this.lastClipboardMonitorLoopError === null || + this.lastClipboardMonitorLoopError.toString() !== err.toString(); + // Prevent spamming the console with the same error + if (printError) { + console.error('Clipboard monitoring error: ' + err); + } + this.lastClipboardMonitorLoopError = err; + } + } finally { + if (!get(isComponentDestroyed)) { + setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL); + } + } + } +} diff --git a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts index f3325f11f5..ea9ffae617 100644 --- a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts +++ b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts @@ -36,6 +36,7 @@ export class RemoteDesktopService { private cursorHasOverride: boolean = false; private lastCursorStyle: string = 'default'; private enableClipboard: boolean = true; + private _autoClipboard: boolean = true; resizeObservable: Observable = new Observable(); @@ -54,21 +55,28 @@ export class RemoteDesktopService { loggingService.info('Web bridge initialized.'); } + get autoClipboard(): boolean { + return this._autoClipboard; + } + // If set to false, the clipboard will not be enabled and the callbacks will not be registered to the Rust side setEnableClipboard(enable: boolean) { this.enableClipboard = enable; } + // If set to true, automatic clipboard synchronization with the server is enabled. + // + // If set to false, then the client must invoke `PublicAPI.saveRemoteClipboardData` and + // `PublicAPI.sendClipboardData` to write to clipboard and to send clipboard data to the server. + setEnableAutoClipboard(enable: boolean) { + this._autoClipboard = enable; + } + /// Callback to set the local clipboard content to data received from the remote. setOnRemoteClipboardChanged(callback: OnRemoteClipboardChanged) { this.onRemoteClipboardChanged = callback; } - /// Callback which is called when the remote sends a list of supported clipboard formats. - setOnRemoteReceivedFormatList(callback: OnRemoteReceivedFormatsList) { - this.onRemoteReceivedFormatList = callback; - } - /// Callback which is called when the remote requests a forced clipboard update (e.g. on /// clipboard initialization sequence) setOnForceClipboardUpdate(callback: OnForceClipboardUpdate) { @@ -273,6 +281,10 @@ export class RemoteDesktopService { this.session?.invokeExtension(ext); } + raiseSessionEvent(event: SessionEvent) { + this.sessionEventObservable.publish(event); + } + private releaseAllInputs() { this.session?.releaseAllInputs(); } @@ -419,10 +431,6 @@ export class RemoteDesktopService { ); } - private raiseSessionEvent(event: SessionEvent) { - this.sessionEventObservable.publish(event); - } - private updateModifierKeyState(evt: KeyboardEvent) { const modKey: ModifierKey = ModifierKey[evt.code as keyof typeof ModifierKey]; From 5d0c74df91be955daeed111733df700c1d49a3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Fri, 29 Aug 2025 00:30:03 -0400 Subject: [PATCH 002/325] chore(release): prepare web packages for publishing (#950) * iron-remote-desktop v0.8.0 * iron-remote-desktop-rdp v0.5.0 --- web-client/iron-remote-desktop-rdp/public/package.json | 2 +- web-client/iron-remote-desktop/public/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web-client/iron-remote-desktop-rdp/public/package.json b/web-client/iron-remote-desktop-rdp/public/package.json index 32d7ef6e00..fc788cfc08 100644 --- a/web-client/iron-remote-desktop-rdp/public/package.json +++ b/web-client/iron-remote-desktop-rdp/public/package.json @@ -6,7 +6,7 @@ "Benoit Cortier" ], "description": "RDP backend for iron-remote-desktop", - "version": "0.5.2", + "version": "0.6.0", "main": "iron-remote-desktop-rdp.js", "types": "index.d.ts", "files": [ diff --git a/web-client/iron-remote-desktop/public/package.json b/web-client/iron-remote-desktop/public/package.json index cd9889f38e..abe9b0954e 100644 --- a/web-client/iron-remote-desktop/public/package.json +++ b/web-client/iron-remote-desktop/public/package.json @@ -10,7 +10,7 @@ "Alexandr Yusuk" ], "description": "Backend-agnostic Web Component for remote desktop protocols", - "version": "0.7.0", + "version": "0.8.0", "main": "iron-remote-desktop.js", "types": "index.d.ts", "files": [ From a3b2017e5f8c298b7f7863c11913c0f0f7a78432 Mon Sep 17 00:00:00 2001 From: devolutionsbot <31221910+devolutionsbot@users.noreply.github.com> Date: Fri, 29 Aug 2025 09:59:13 -0400 Subject: [PATCH 003/325] chore(release): prepare for publishing (#885) --- Cargo.lock | 358 ++++++++++----------- crates/iron-remote-desktop/CHANGELOG.md | 6 + crates/iron-remote-desktop/Cargo.toml | 2 +- crates/ironrdp-acceptor/Cargo.toml | 10 +- crates/ironrdp-ainput/Cargo.toml | 4 +- crates/ironrdp-async/Cargo.toml | 6 +- crates/ironrdp-blocking/Cargo.toml | 6 +- crates/ironrdp-client/Cargo.toml | 8 +- crates/ironrdp-cliprdr-native/CHANGELOG.md | 17 +- crates/ironrdp-cliprdr-native/Cargo.toml | 4 +- crates/ironrdp-cliprdr/CHANGELOG.md | 6 + crates/ironrdp-cliprdr/Cargo.toml | 6 +- crates/ironrdp-connector/CHANGELOG.md | 28 ++ crates/ironrdp-connector/Cargo.toml | 6 +- crates/ironrdp-displaycontrol/Cargo.toml | 8 +- crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md | 18 ++ crates/ironrdp-dvc-pipe-proxy/Cargo.toml | 8 +- crates/ironrdp-dvc/Cargo.toml | 6 +- crates/ironrdp-futures/Cargo.toml | 4 +- crates/ironrdp-graphics/Cargo.toml | 4 +- crates/ironrdp-input/Cargo.toml | 4 +- crates/ironrdp-pdu/CHANGELOG.md | 24 +- crates/ironrdp-pdu/Cargo.toml | 2 +- crates/ironrdp-rdcleanpath/CHANGELOG.md | 9 + crates/ironrdp-rdcleanpath/Cargo.toml | 2 +- crates/ironrdp-rdpdr-native/CHANGELOG.md | 7 +- crates/ironrdp-rdpdr-native/Cargo.toml | 8 +- crates/ironrdp-rdpdr/Cargo.toml | 6 +- crates/ironrdp-rdpsnd-native/CHANGELOG.md | 6 + crates/ironrdp-rdpsnd-native/Cargo.toml | 4 +- crates/ironrdp-rdpsnd/Cargo.toml | 6 +- crates/ironrdp-server/CHANGELOG.md | 21 ++ crates/ironrdp-server/Cargo.toml | 24 +- crates/ironrdp-session/CHANGELOG.md | 14 + crates/ironrdp-session/Cargo.toml | 14 +- crates/ironrdp-svc/Cargo.toml | 4 +- crates/ironrdp-tls/CHANGELOG.md | 8 +- crates/ironrdp-tls/Cargo.toml | 2 +- crates/ironrdp-tokio/Cargo.toml | 6 +- crates/ironrdp/CHANGELOG.md | 6 + crates/ironrdp/Cargo.toml | 32 +- fuzz/Cargo.lock | 83 +++-- 42 files changed, 474 insertions(+), 333 deletions(-) create mode 100644 crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md diff --git a/Cargo.lock b/Cargo.lock index 6495ccd999..64f01fc627 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,9 +14,9 @@ dependencies = [ [[package]] name = "ab_glyph_rasterizer" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2187590a23ab1e3df8681afdf0987c48504d80291f002fcdb651f0ef5e25169" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "addr2line" @@ -171,9 +171,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -201,22 +201,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -470,9 +470,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bit_field" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" @@ -627,9 +627,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" dependencies = [ "jobserver", "libc", @@ -653,9 +653,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -727,9 +727,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.45" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" dependencies = [ "clap_builder", "clap_derive", @@ -737,9 +737,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.44" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" dependencies = [ "anstream", "anstyle", @@ -1186,9 +1186,9 @@ dependencies = [ [[package]] name = "derive_arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", @@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.9.3", - "objc2 0.6.1", + "objc2 0.6.2", ] [[package]] @@ -1354,9 +1354,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" @@ -1765,7 +1765,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.3+wasi-0.2.4", "wasm-bindgen", ] @@ -1787,9 +1787,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gloo-net" @@ -1847,9 +1847,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -1876,9 +1876,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "heck" @@ -2236,9 +2236,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown", @@ -2273,9 +2273,9 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ "bitflags 2.9.3", "cfg-if", @@ -2312,7 +2312,7 @@ dependencies = [ [[package]] name = "iron-remote-desktop" -version = "0.4.0" +version = "0.5.0" dependencies = [ "console_error_panic_hook", "tracing", @@ -2324,7 +2324,7 @@ dependencies = [ [[package]] name = "ironrdp" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "async-trait", @@ -2357,7 +2357,7 @@ dependencies = [ [[package]] name = "ironrdp-acceptor" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ironrdp-async", "ironrdp-connector", @@ -2369,7 +2369,7 @@ dependencies = [ [[package]] name = "ironrdp-ainput" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bitflags 2.9.3", "ironrdp-core", @@ -2380,7 +2380,7 @@ dependencies = [ [[package]] name = "ironrdp-async" -version = "0.6.0" +version = "0.7.0" dependencies = [ "bytes", "ironrdp-connector", @@ -2401,7 +2401,7 @@ dependencies = [ [[package]] name = "ironrdp-blocking" -version = "0.6.0" +version = "0.7.0" dependencies = [ "bytes", "ironrdp-connector", @@ -2459,7 +2459,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bitflags 2.9.3", "ironrdp-core", @@ -2478,7 +2478,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-native" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ironrdp-cliprdr", "ironrdp-core", @@ -2488,7 +2488,7 @@ dependencies = [ [[package]] name = "ironrdp-connector" -version = "0.6.0" +version = "0.7.0" dependencies = [ "arbitrary", "ironrdp-core", @@ -2513,7 +2513,7 @@ dependencies = [ [[package]] name = "ironrdp-displaycontrol" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2524,7 +2524,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.3.1" +version = "0.4.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2535,7 +2535,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc-pipe-proxy" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "ironrdp-core", @@ -2552,7 +2552,7 @@ version = "0.1.3" [[package]] name = "ironrdp-futures" -version = "0.4.0" +version = "0.5.0" dependencies = [ "bytes", "futures-util", @@ -2577,7 +2577,7 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.4.1" +version = "0.5.0" dependencies = [ "bit_field", "bitflags 2.9.3", @@ -2596,7 +2596,7 @@ dependencies = [ [[package]] name = "ironrdp-input" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bitvec", "ironrdp-pdu", @@ -2625,7 +2625,7 @@ dependencies = [ [[package]] name = "ironrdp-pdu" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bit_field", "bitflags 2.9.3", @@ -2660,14 +2660,14 @@ dependencies = [ [[package]] name = "ironrdp-rdcleanpath" -version = "0.1.3" +version = "0.2.0" dependencies = [ "der", ] [[package]] name = "ironrdp-rdpdr" -version = "0.3.0" +version = "0.4.0" dependencies = [ "bitflags 2.9.3", "ironrdp-core", @@ -2679,7 +2679,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr-native" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2698,7 +2698,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitflags 2.9.3", "ironrdp-core", @@ -2709,7 +2709,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd-native" -version = "0.3.1" +version = "0.4.0" dependencies = [ "anyhow", "bytemuck", @@ -2722,7 +2722,7 @@ dependencies = [ [[package]] name = "ironrdp-server" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "async-trait", @@ -2752,7 +2752,7 @@ dependencies = [ [[package]] name = "ironrdp-session" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironrdp-connector", "ironrdp-core", @@ -2773,7 +2773,7 @@ version = "0.0.0" [[package]] name = "ironrdp-svc" -version = "0.4.1" +version = "0.5.0" dependencies = [ "bitflags 2.9.3", "ironrdp-core", @@ -2830,7 +2830,7 @@ dependencies = [ [[package]] name = "ironrdp-tls" -version = "0.1.3" +version = "0.1.4" dependencies = [ "tokio", "tokio-native-tls", @@ -2840,7 +2840,7 @@ dependencies = [ [[package]] name = "ironrdp-tokio" -version = "0.6.0" +version = "0.7.0" dependencies = [ "bytes", "ironrdp-async", @@ -2943,9 +2943,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom 0.3.3", "libc", @@ -2987,9 +2987,9 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libloading" @@ -2998,7 +2998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -3009,13 +3009,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags 2.9.3", "libc", - "redox_syscall 0.5.15", + "redox_syscall 0.5.17", ] [[package]] @@ -3124,9 +3124,9 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memmap2" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28" +checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" dependencies = [ "libc", ] @@ -3373,9 +3373,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" dependencies = [ "objc2-encode", ] @@ -3404,7 +3404,7 @@ checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" dependencies = [ "bitflags 2.9.3", "libc", - "objc2 0.6.1", + "objc2 0.6.2", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -3442,7 +3442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" dependencies = [ "dispatch2", - "objc2 0.6.1", + "objc2 0.6.2", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -3454,7 +3454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" dependencies = [ "bitflags 2.9.3", - "objc2 0.6.1", + "objc2 0.6.2", ] [[package]] @@ -3477,7 +3477,7 @@ checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ "bitflags 2.9.3", "dispatch2", - "objc2 0.6.1", + "objc2 0.6.2", ] [[package]] @@ -3529,7 +3529,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "objc2 0.6.1", + "objc2 0.6.2", ] [[package]] @@ -3737,9 +3737,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "owned_ttf_parser" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ "ttf-parser", ] @@ -3800,7 +3800,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.15", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -3919,9 +3919,9 @@ dependencies = [ [[package]] name = "picky-krb" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45ffe5f2122cdda5e9059ab837a65ba1b77729db43fc1500f2fce6b27070eab" +checksum = "1e78a55491723b0a10bc2c02709a8d92d74ef674fe1b569cb4a08bac3d105487" dependencies = [ "aes", "byteorder", @@ -4050,9 +4050,9 @@ dependencies = [ [[package]] name = "polling" -version = "3.9.0" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee9b2fa7a4517d2c91ff5bc6c297a427a96749d15f98fcdbb22c05571a4d4b7" +checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" dependencies = [ "cfg-if", "concurrent-queue", @@ -4085,9 +4085,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -4119,9 +4119,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", @@ -4153,9 +4153,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -4174,7 +4174,7 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", "rusty-fork", "tempfile", "unarray", @@ -4206,9 +4206,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -4217,7 +4217,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.0", "thiserror 2.0.16", "tokio", "tracing", @@ -4226,9 +4226,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", "getrandom 0.3.3", @@ -4247,16 +4247,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.13" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.0", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4356,9 +4356,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -4366,9 +4366,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -4394,23 +4394,23 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags 2.9.3", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata 0.4.10", + "regex-syntax 0.8.6", ] [[package]] @@ -4424,13 +4424,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] @@ -4441,9 +4441,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "relative-path" @@ -4596,9 +4596,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -4658,9 +4658,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "aws-lc-rs", "log", @@ -4681,7 +4681,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", + "security-framework 3.3.0", ] [[package]] @@ -4717,9 +4717,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" @@ -4811,9 +4811,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" dependencies = [ "bitflags 2.9.3", "core-foundation 0.10.1", @@ -4869,9 +4869,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -4970,9 +4970,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -5079,7 +5079,7 @@ dependencies = [ "objc2-foundation 0.2.2", "objc2-quartz-core", "raw-window-handle", - "redox_syscall 0.5.15", + "redox_syscall 0.5.17", "rustix 0.38.44", "tiny-xlib", "wasm-bindgen", @@ -5197,9 +5197,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -5255,15 +5255,15 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5413,9 +5413,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -5531,9 +5531,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ "indexmap", "serde", @@ -5572,9 +5572,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ "winnow", ] @@ -5926,11 +5926,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.3+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -6012,13 +6012,13 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.44", + "rustix 1.0.8", "scoped-tls", "smallvec", "wayland-sys", @@ -6026,12 +6026,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags 2.9.3", - "rustix 0.38.44", + "rustix 1.0.8", "wayland-backend", "wayland-scanner", ] @@ -6049,20 +6049,20 @@ dependencies = [ [[package]] name = "wayland-cursor" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65317158dec28d00416cb16705934070aef4f8393353d41126c54264ae0f182" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 0.38.44", + "rustix 1.0.8", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.8" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ "bitflags 2.9.3", "wayland-backend", @@ -6072,9 +6072,9 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd38cdad69b56ace413c6bcc1fbf5acc5e2ef4af9d5f8f1f9570c0c83eae175" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ "bitflags 2.9.3", "wayland-backend", @@ -6085,9 +6085,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ "bitflags 2.9.3", "wayland-backend", @@ -6098,9 +6098,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", "quick-xml", @@ -6109,9 +6109,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "dlib", "log", @@ -6195,11 +6195,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6392,7 +6392,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -6443,10 +6443,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -6700,9 +6701,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -6728,13 +6729,10 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.3", -] +checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" [[package]] name = "writeable" @@ -6970,9 +6968,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", diff --git a/crates/iron-remote-desktop/CHANGELOG.md b/crates/iron-remote-desktop/CHANGELOG.md index 1a86a0e438..eb96e22bf0 100644 --- a/crates/iron-remote-desktop/CHANGELOG.md +++ b/crates/iron-remote-desktop/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.4.0...iron-remote-desktop-v0.5.0)] - 2025-08-29 + +### Bug Fixes + +- [**breaking**] Remove the `remote_received_format_list_callback` method from Session common API (#935) ([5b948e2161](https://github.com/Devolutions/IronRDP/commit/5b948e2161b08b13d32bdbb480b26c8fa44d42f7)) + ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.3.0...iron-remote-desktop-v0.4.0)] - 2025-06-27 ### Features diff --git a/crates/iron-remote-desktop/Cargo.toml b/crates/iron-remote-desktop/Cargo.toml index bed7538ea3..4ae338e209 100644 --- a/crates/iron-remote-desktop/Cargo.toml +++ b/crates/iron-remote-desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iron-remote-desktop" -version = "0.4.0" +version = "0.5.0" readme = "README.md" description = "Helper crate for building WASM modules compatible with iron-remote-desktop WebComponent" edition.workspace = true diff --git a/crates/ironrdp-acceptor/Cargo.toml b/crates/ironrdp-acceptor/Cargo.toml index 3327ee5c6a..a4ef47dbfa 100644 --- a/crates/ironrdp-acceptor/Cargo.toml +++ b/crates/ironrdp-acceptor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-acceptor" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "State machines to drive an RDP connection acceptance sequence" edition.workspace = true @@ -17,10 +17,10 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6" } # public -ironrdp-async = { path = "../ironrdp-async", version = "0.6" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.7" } # public +ironrdp-async = { path = "../ironrdp-async", version = "0.7" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-ainput/Cargo.toml b/crates/ironrdp-ainput/Cargo.toml index b419660c20..f2e4037a80 100644 --- a/crates/ironrdp-ainput/Cargo.toml +++ b/crates/ironrdp-ainput/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-ainput" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "AInput dynamic channel implementation" edition.workspace = true @@ -17,7 +17,7 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.4" } # public bitflags = "2.9" num-derive.workspace = true # TODO: remove num-traits.workspace = true # TODO: remove diff --git a/crates/ironrdp-async/Cargo.toml b/crates/ironrdp-async/Cargo.toml index 77c51bfb16..31a81d0128 100644 --- a/crates/ironrdp-async/Cargo.toml +++ b/crates/ironrdp-async/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-async" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "Provides `Future`s wrapping the IronRDP state machines conveniently" edition.workspace = true @@ -16,9 +16,9 @@ doctest = false test = false [dependencies] -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.7" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public tracing = { version = "0.1", features = ["log"] } bytes = "1" # public diff --git a/crates/ironrdp-blocking/Cargo.toml b/crates/ironrdp-blocking/Cargo.toml index 011c7a6f43..3ce5774e55 100644 --- a/crates/ironrdp-blocking/Cargo.toml +++ b/crates/ironrdp-blocking/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-blocking" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "Blocking I/O abstraction wrapping the IronRDP state machines conveniently" edition.workspace = true @@ -16,9 +16,9 @@ doctest = false test = false [dependencies] -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.7" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public tracing = { version = "0.1", features = ["log"] } bytes = "1" # public diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 5495031b15..e3189c5745 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -32,7 +32,7 @@ qoiz = ["ironrdp/qoiz"] [dependencies] # Protocols -ironrdp = { path = "../ironrdp", version = "0.11", features = [ +ironrdp = { path = "../ironrdp", version = "0.12", features = [ "session", "input", "graphics", @@ -45,11 +45,11 @@ ironrdp = { path = "../ironrdp", version = "0.11", features = [ "connector", ] } ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.3" } -ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.3" } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.4" } +ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.4" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.1" } ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } -ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.6", features = ["reqwest"] } +ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.7", features = ["reqwest"] } ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" ironrdp-propertyset.path = "../ironrdp-propertyset" diff --git a/crates/ironrdp-cliprdr-native/CHANGELOG.md b/crates/ironrdp-cliprdr-native/CHANGELOG.md index 69f3ee386c..27b65067d8 100644 --- a/crates/ironrdp-cliprdr-native/CHANGELOG.md +++ b/crates/ironrdp-cliprdr-native/CHANGELOG.md @@ -6,6 +6,19 @@ 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). +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.3.0...ironrdp-cliprdr-native-v0.4.0)] - 2025-08-29 + +### Bug Fixes + +- Map `E_ACCESSDENIED` WinAPI error code to `ClipboardAccessDenied` error (#936) ([b0c145d0d9](https://github.com/Devolutions/IronRDP/commit/b0c145d0d9cf2f347e537c08ce9d6c35223823d5)) + + When the system clipboard updates, we receive an `Updated` event. Then + we try to open it, but we can get `AccessDenied` error because the + clipboard may still be locked for another window (like _Notepad_). To + handle this, we have special logic that attempts to open the clipboard + in the event of such errors. + The problem is that so far, the `ClipboardAccessDenied` error was not mapped. + ## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.3...ironrdp-cliprdr-native-v0.1.4)] - 2025-03-12 ### Build @@ -20,16 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Previously, the function handled only `WM_ACTIVATE`. - - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.1...ironrdp-cliprdr-native-v0.1.2)] - 2025-01-28 ### Documentation - Use CDN URLs instead of the blob storage URLs for Devolutions logo ([#631](https://github.com/Devolutions/IronRDP/issues/631)) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.0...ironrdp-cliprdr-native-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-cliprdr-native/Cargo.toml b/crates/ironrdp-cliprdr-native/Cargo.toml index 34bcd28581..9e3384106c 100644 --- a/crates/ironrdp-cliprdr-native/Cargo.toml +++ b/crates/ironrdp-cliprdr-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-cliprdr-native" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "Native CLIPRDR static channel backend implementations for IronRDP" edition.workspace = true @@ -16,7 +16,7 @@ doctest = false test = false [dependencies] -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.3" } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.4" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.1" } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-cliprdr/CHANGELOG.md b/crates/ironrdp-cliprdr/CHANGELOG.md index 2934a4cebd..d96b47604a 100644 --- a/crates/ironrdp-cliprdr/CHANGELOG.md +++ b/crates/ironrdp-cliprdr/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.3.0...ironrdp-cliprdr-v0.4.0)] - 2025-08-29 + +### Bug Fixes + +- [**breaking**] Remove the `on_format_list_received` callback (#935) ([5b948e2161](https://github.com/Devolutions/IronRDP/commit/5b948e2161b08b13d32bdbb480b26c8fa44d42f7)) + ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.2.0...ironrdp-cliprdr-v0.3.0)] - 2025-05-27 ### Features diff --git a/crates/ironrdp-cliprdr/Cargo.toml b/crates/ironrdp-cliprdr/Cargo.toml index d0b131c7bb..e02cfefdd5 100644 --- a/crates/ironrdp-cliprdr/Cargo.toml +++ b/crates/ironrdp-cliprdr/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-cliprdr" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "CLIPRDR static channel for clipboard implemented as described in MS-RDPECLIP" edition.workspace = true @@ -17,8 +17,8 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public tracing = { version = "0.1", features = ["log"] } bitflags = "2.9" diff --git a/crates/ironrdp-connector/CHANGELOG.md b/crates/ironrdp-connector/CHANGELOG.md index 7481c6b25f..4979ced9cc 100644 --- a/crates/ironrdp-connector/CHANGELOG.md +++ b/crates/ironrdp-connector/CHANGELOG.md @@ -6,6 +6,34 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.6.0...ironrdp-connector-v0.7.0)] - 2025-08-29 + +### Features + +- Add QOI image codec ([613fd51f26](https://github.com/Devolutions/IronRDP/commit/613fd51f26315d8212662c46f8e625c541e4bb59)) + + The Quite OK Image format ([1]) losslessly compresses images to a similar size + of PNG, while offering 20x-50x faster encoding and 3x-4x faster decoding. + +- Add QOIZ image codec ([87df67fdc7](https://github.com/Devolutions/IronRDP/commit/87df67fdc76ff4f39d4b83521e34bf3b5e2e73bb)) + + Add a new QOIZ codec for SetSurface command. The PDU data contains the same + data as the QOI codec, with zstd compression. + +- Add an option to specify a timezone (#917) ([6fab9f8228](https://github.com/Devolutions/IronRDP/commit/6fab9f8228578b3c78db131b3c2e0526352116a9)) + +### Bug Fixes + +- [**breaking**] Rename option no_server_pointer into enable_server_pointer ([218fed03c7](https://github.com/Devolutions/IronRDP/commit/218fed03c7993af0f958453e3944c58bcf9f43cb)) + +- [**breaking**] Rename option no_audio_playback into enable_audio_playback ([5d8a487001](https://github.com/Devolutions/IronRDP/commit/5d8a487001c1280cbaf9f581f2a9a2f47d187bf0)) + +### Build + +- Bump rand to 0.9 ([de0877188c](https://github.com/Devolutions/IronRDP/commit/de0877188cbb3692c3ce0d9a72f6e96d515cde1f)) + +- Bump picky from 7.0.0-rc.16 to 7.0.0-rc.17 (#941) ([fe31cf2c57](https://github.com/Devolutions/IronRDP/commit/fe31cf2c574e0b06177a931db4cac95ea9cfbe7e)) + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.5.1...ironrdp-connector-v0.6.0)] - 2025-07-08 ### Build diff --git a/crates/ironrdp-connector/Cargo.toml b/crates/ironrdp-connector/Cargo.toml index dd9bd2feb6..31423e3dc4 100644 --- a/crates/ironrdp-connector/Cargo.toml +++ b/crates/ironrdp-connector/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-connector" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "State machines to drive an RDP connection sequence" edition.workspace = true @@ -22,10 +22,10 @@ qoi = ["ironrdp-pdu/qoi"] qoiz = ["ironrdp-pdu/qoiz"] [dependencies] -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["std"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", features = ["std"] } # public arbitrary = { version = "1", features = ["derive"], optional = true } # public sspi = "0.16" # public url = "2.5" # public diff --git a/crates/ironrdp-displaycontrol/Cargo.toml b/crates/ironrdp-displaycontrol/Cargo.toml index 7c3424ff1a..0c336d5000 100644 --- a/crates/ironrdp-displaycontrol/Cargo.toml +++ b/crates/ironrdp-displaycontrol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-displaycontrol" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "Display control dynamic channel extension implementation" edition.workspace = true @@ -17,9 +17,9 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.4" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md new file mode 100644 index 0000000000..df183c39f3 --- /dev/null +++ b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.1.0...ironrdp-dvc-pipe-proxy-v0.2.0)] - 2025-08-29 + +### Features + +- Make dvc named pipe proxy cross-platform (#896) ([166b76010c](https://github.com/Devolutions/IronRDP/commit/166b76010cbd8f8674e6e8d4801fee5cda1ad9e5)) + + - Make dvc named pipe proxy cross-platform (Unix implementation via + `tokio::net::unix::UnixStream`) + - Removed unsafe code for Windows implementation, switched to + `tokio::net::windows::named_pipe` diff --git a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml index 924114ab71..93d99e7b88 100644 --- a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml +++ b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc-pipe-proxy" -version = "0.1.0" +version = "0.2.0" readme = "README.md" description = "DVC named pipe proxy for IronRDP" edition.workspace = true @@ -17,9 +17,9 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public (PduResult type) -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public (SvcMessage type) +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public (PduResult type) +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.4" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public (SvcMessage type) tracing = { version = "0.1", features = ["log"] } tokio = { version = "1", features = ["net", "rt", "sync", "macros", "io-util"]} diff --git a/crates/ironrdp-dvc/Cargo.toml b/crates/ironrdp-dvc/Cargo.toml index 5ea17b6436..d286757007 100644 --- a/crates/ironrdp-dvc/Cargo.toml +++ b/crates/ironrdp-dvc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc" -version = "0.3.1" +version = "0.4.0" readme = "README.md" description = "DRDYNVC static channel implementation and traits to implement dynamic virtual channels" edition.workspace = true @@ -21,8 +21,8 @@ std = [] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["alloc"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", features = ["alloc"] } # public tracing = { version = "0.1", features = ["log"] } slab = "0.4" diff --git a/crates/ironrdp-futures/Cargo.toml b/crates/ironrdp-futures/Cargo.toml index 2752bb0a34..aa8e67ecc0 100644 --- a/crates/ironrdp-futures/Cargo.toml +++ b/crates/ironrdp-futures/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-futures" -version = "0.4.0" +version = "0.5.0" readme = "README.md" description = "`Framed*` traits implementation above futures’s traits" edition.workspace = true @@ -17,7 +17,7 @@ test = false [dependencies] futures-util = { version = "0.3", features = ["io"] } # public -ironrdp-async = { path = "../ironrdp-async", version = "0.6" } # public +ironrdp-async = { path = "../ironrdp-async", version = "0.7" } # public bytes = "1" # public [lints] diff --git a/crates/ironrdp-graphics/Cargo.toml b/crates/ironrdp-graphics/Cargo.toml index ab5cc81d4b..a331a4e753 100644 --- a/crates/ironrdp-graphics/Cargo.toml +++ b/crates/ironrdp-graphics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-graphics" -version = "0.4.1" +version = "0.5.0" readme = "README.md" description = "RDP image processing primitives" edition.workspace = true @@ -20,7 +20,7 @@ bit_field = "0.10" bitflags = "2.9" bitvec = "1.0" ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["std"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", features = ["std"] } # public byteorder = "1.5" # TODO: remove lazy_static.workspace = true # Legacy crate; prefer std::sync::LazyLock or LazyCell num-derive.workspace = true # TODO: remove diff --git a/crates/ironrdp-input/Cargo.toml b/crates/ironrdp-input/Cargo.toml index ca27a4c2ef..2f3b78c99e 100644 --- a/crates/ironrdp-input/Cargo.toml +++ b/crates/ironrdp-input/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-input" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "Utilities to manage and build RDP input packets" edition.workspace = true @@ -16,7 +16,7 @@ doctest = false test = false [dependencies] -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public bitvec = "1.0" smallvec = "1.15" diff --git a/crates/ironrdp-pdu/CHANGELOG.md b/crates/ironrdp-pdu/CHANGELOG.md index 27ab39c6c3..ba7ae8cdbf 100644 --- a/crates/ironrdp-pdu/CHANGELOG.md +++ b/crates/ironrdp-pdu/CHANGELOG.md @@ -6,6 +6,25 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.5.0...ironrdp-pdu-v0.6.0)] - 2025-08-29 + +### Features + +- Implement `Default` trait on `ExtendedClientOptionalInfoBuilder` (#891) ([ae052ed835](https://github.com/Devolutions/IronRDP/commit/ae052ed83598ad1f4ad7038b153e3c5398d2a738)) + +### Bug Fixes + +- [**breaking**] Update timezone info to use i32 bias (#921) ([119c7077c9](https://github.com/Devolutions/IronRDP/commit/119c7077c98e4b43021619378c4f251c1f95ae17)) + + Switches `bias` from an unsigned to a signed integer. + This matches the updated specification from Microsoft. + +### Build + +- Bump thiserror to 2.0 ([b4fb0aa0c7](https://github.com/Devolutions/IronRDP/commit/b4fb0aa0c79aa409d1b6a5f43ab23448eede4e51)) + +- Bump der-parser to 10.0 ([03cac54ada](https://github.com/Devolutions/IronRDP/commit/03cac54ada50fae13d085b855a9b8db37d615ba8)) + ## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.4.0...ironrdp-pdu-v0.5.0)] - 2025-05-27 ### Features @@ -20,7 +39,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 An index bound check was missing in the RFX module. Found by fuzzer. - ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.3.1...ironrdp-pdu-v0.4.0)] - 2025-03-12 ### Bug Fixes @@ -55,8 +73,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This fixes random error/disconnect in client. - - ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.1.2...ironrdp-pdu-v0.2.0)] - 2025-01-28 ### Features @@ -67,8 +83,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.1.1...ironrdp-pdu-v0.1.2)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-pdu/Cargo.toml b/crates/ironrdp-pdu/Cargo.toml index 241a2a1631..ff8a9d94a9 100644 --- a/crates/ironrdp-pdu/Cargo.toml +++ b/crates/ironrdp-pdu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-pdu" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "RDP PDU encoding and decoding" edition.workspace = true diff --git a/crates/ironrdp-rdcleanpath/CHANGELOG.md b/crates/ironrdp-rdcleanpath/CHANGELOG.md index ef774cfbfd..fa6410ca6e 100644 --- a/crates/ironrdp-rdcleanpath/CHANGELOG.md +++ b/crates/ironrdp-rdcleanpath/CHANGELOG.md @@ -6,6 +6,15 @@ 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). +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdcleanpath-v0.1.3...ironrdp-rdcleanpath-v0.2.0)] - 2025-08-29 + +### Features + +- [**breaking**] Extend helper API for handling negotiation errors (#930) ([ca11e338d7](https://github.com/Devolutions/IronRDP/commit/ca11e338d7231c86f60a110627a5d864377d8594)) + + - Helper for proxies creating an RDCleanPath error with server response. + - Helper for clients to handle these. + ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdcleanpath-v0.1.2...ironrdp-rdcleanpath-v0.1.3)] - 2025-03-12 ### Build diff --git a/crates/ironrdp-rdcleanpath/Cargo.toml b/crates/ironrdp-rdcleanpath/Cargo.toml index 17f4e0fbd7..9e47b1fa7b 100644 --- a/crates/ironrdp-rdcleanpath/Cargo.toml +++ b/crates/ironrdp-rdcleanpath/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdcleanpath" -version = "0.1.3" +version = "0.2.0" readme = "README.md" description = "RDCleanPath PDU structure used by IronRDP web client and Devolutions Gateway" edition.workspace = true diff --git a/crates/ironrdp-rdpdr-native/CHANGELOG.md b/crates/ironrdp-rdpdr-native/CHANGELOG.md index 148a88331a..4370951c3d 100644 --- a/crates/ironrdp-rdpdr-native/CHANGELOG.md +++ b/crates/ironrdp-rdpdr-native/CHANGELOG.md @@ -6,13 +6,18 @@ 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). -## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.1.2...ironrdp-rdpdr-native-v0.2.0)] - 2025-03-12 +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.3.0...ironrdp-rdpdr-native-v0.4.0)] - 2025-08-29 ### Build +- Bump nix to 0.30 ([971ad922a5](https://github.com/Devolutions/IronRDP/commit/971ad922a51f78511243aaa885acdd8b1ed94b27)) - Bump ironrdp-pdu +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.1.2...ironrdp-rdpdr-native-v0.2.0)] - 2025-03-12 +### Build + +- Bump ironrdp-pdu ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.1.1...ironrdp-rdpdr-native-v0.1.2)] - 2025-03-12 diff --git a/crates/ironrdp-rdpdr-native/Cargo.toml b/crates/ironrdp-rdpdr-native/Cargo.toml index 20f59eb46e..1d61a701bf 100644 --- a/crates/ironrdp-rdpdr-native/Cargo.toml +++ b/crates/ironrdp-rdpdr-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpdr-native" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "Native RDPDR static channel backend implementations for IronRDP" edition.workspace = true @@ -17,8 +17,8 @@ test = false [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.3" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.4" } # public nix = { version = "0.30", features = ["fs", "dir"] } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-rdpdr/Cargo.toml b/crates/ironrdp-rdpdr/Cargo.toml index da29b2a754..c94aa7eea0 100644 --- a/crates/ironrdp-rdpdr/Cargo.toml +++ b/crates/ironrdp-rdpdr/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpdr" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "RDPDR channel implementation." edition.workspace = true @@ -18,8 +18,8 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public tracing = { version = "0.1", features = ["log"] } bitflags = "2.9" diff --git a/crates/ironrdp-rdpsnd-native/CHANGELOG.md b/crates/ironrdp-rdpsnd-native/CHANGELOG.md index 25bbb0a830..c56edf9394 100644 --- a/crates/ironrdp-rdpsnd-native/CHANGELOG.md +++ b/crates/ironrdp-rdpsnd-native/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.3.1...ironrdp-rdpsnd-native-v0.4.0)] - 2025-08-29 + +### Build + +- Bump cpal to 0.16 ([eeac1fee1f](https://github.com/Devolutions/IronRDP/commit/eeac1fee1fed4858f4776d86072790bc074e34eb)) + ## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.3.0...ironrdp-rdpsnd-native-v0.3.1)] - 2025-06-27 ### Build diff --git a/crates/ironrdp-rdpsnd-native/Cargo.toml b/crates/ironrdp-rdpsnd-native/Cargo.toml index fc32a12b68..95647c66c0 100644 --- a/crates/ironrdp-rdpsnd-native/Cargo.toml +++ b/crates/ironrdp-rdpsnd-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpsnd-native" -version = "0.3.1" +version = "0.4.0" description = "Native RDPSND static channel backend implementations for IronRDP" edition.workspace = true license.workspace = true @@ -22,7 +22,7 @@ opus = ["dep:opus", "dep:bytemuck"] anyhow = "1" bytemuck = { version = "1.23", optional = true } cpal = "0.16" -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.5" } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.6" } # public opus = { version = "0.3", optional = true } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-rdpsnd/Cargo.toml b/crates/ironrdp-rdpsnd/Cargo.toml index 93e74fce96..4f4b6e5b15 100644 --- a/crates/ironrdp-rdpsnd/Cargo.toml +++ b/crates/ironrdp-rdpsnd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpsnd" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "RDPSND static channel for audio output implemented as described in MS-RDPEA" edition.workspace = true @@ -22,9 +22,9 @@ std = [] [dependencies] bitflags = "2.9" tracing = { version = "0.1", features = ["log"] } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", features = ["alloc"] } # public [lints] workspace = true diff --git a/crates/ironrdp-server/CHANGELOG.md b/crates/ironrdp-server/CHANGELOG.md index 2c57beee72..42e6b62c0d 100644 --- a/crates/ironrdp-server/CHANGELOG.md +++ b/crates/ironrdp-server/CHANGELOG.md @@ -6,6 +6,27 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.7.0...ironrdp-server-v0.8.0)] - 2025-08-29 + +### Features + +- [**breaking**] Add server_codecs_capabilities() ([d3aaa43c23](https://github.com/Devolutions/IronRDP/commit/d3aaa43c23b252077b8720bb8ecfeceaaf7b7a7f)) + + Teach the server to support customizable codecs set. Use the same + logic/parsing as the client codecs configuration. + + Replace "with_remote_fx" with "codecs". + +- Add QOI image codec ([613fd51f26](https://github.com/Devolutions/IronRDP/commit/613fd51f26315d8212662c46f8e625c541e4bb59)) + + The Quite OK Image format ([1]) losslessly compresses images to a similar size + of PNG, while offering 20x-50x faster encoding and 3x-4x faster decoding. + +- Add QOIZ image codec ([87df67fdc7](https://github.com/Devolutions/IronRDP/commit/87df67fdc76ff4f39d4b83521e34bf3b5e2e73bb)) + + Add a new QOIZ codec for SetSurface command. The PDU data contains the same + data as the QOI codec, with zstd compression. + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.6.1...ironrdp-server-v0.7.0)] - 2025-07-08 ### Build diff --git a/crates/ironrdp-server/Cargo.toml b/crates/ironrdp-server/Cargo.toml index 2c1ca031c2..9f3aeebe01 100644 --- a/crates/ironrdp-server/Cargo.toml +++ b/crates/ironrdp-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-server" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "Extendable skeleton for implementing custom RDP servers" edition.workspace = true @@ -31,18 +31,18 @@ anyhow = "1.0" tokio = { version = "1", features = ["net", "macros", "sync", "rt"] } # public tokio-rustls = "0.26" # public async-trait = "0.1" -ironrdp-async = { path = "../ironrdp-async", version = "0.6" } -ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.3" } +ironrdp-async = { path = "../ironrdp-async", version = "0.7" } +ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.4" } ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.3" } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.3" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } # public -ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.6" } -ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.6" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.4" } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.5" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.4" } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.4" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.4" } # public +ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.7" } +ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.7" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.5" } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.6" } # public tracing = { version = "0.1", features = ["log"] } x509-cert = { version = "0.2.5", optional = true } rustls-pemfile = { version = "2.2.0", optional = true } diff --git a/crates/ironrdp-session/CHANGELOG.md b/crates/ironrdp-session/CHANGELOG.md index da95cfd06a..3b043327ba 100644 --- a/crates/ironrdp-session/CHANGELOG.md +++ b/crates/ironrdp-session/CHANGELOG.md @@ -6,6 +6,20 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.5.0...ironrdp-session-v0.6.0)] - 2025-08-29 + +### Features + +- Add QOI image codec ([613fd51f26](https://github.com/Devolutions/IronRDP/commit/613fd51f26315d8212662c46f8e625c541e4bb59)) + + The Quite OK Image format ([1]) losslessly compresses images to a similar size + of PNG, while offering 20x-50x faster encoding and 3x-4x faster decoding. + +- Add QOIZ image codec ([87df67fdc7](https://github.com/Devolutions/IronRDP/commit/87df67fdc76ff4f39d4b83521e34bf3b5e2e73bb)) + + Add a new QOIZ codec for SetSurface command. The PDU data contains the same + data as the QOI codec, with zstd compression. + ## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.4.0...ironrdp-session-v0.4.1)] - 2025-06-27 ### Features diff --git a/crates/ironrdp-session/Cargo.toml b/crates/ironrdp-session/Cargo.toml index e498c3eaf1..ceff1df111 100644 --- a/crates/ironrdp-session/Cargo.toml +++ b/crates/ironrdp-session/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-session" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "State machines to drive an RDP session" edition.workspace = true @@ -22,13 +22,13 @@ qoiz = ["dep:zstd-safe", "qoi"] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6" } # public # TODO: at some point, this dependency could be removed (good for compilation speed) -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.7" } # public # TODO: at some point, this dependency could be removed (good for compilation speed) +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.4" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.4" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["std"] } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.3" } +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.5" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", features = ["std"] } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.4" } tracing = { version = "0.1", features = ["log"] } qoicoubeh = { version = "0.5", optional = true } zstd-safe = { version = "7.2", optional = true, features = ["std"] } diff --git a/crates/ironrdp-svc/Cargo.toml b/crates/ironrdp-svc/Cargo.toml index f78b38cf08..048297c40e 100644 --- a/crates/ironrdp-svc/Cargo.toml +++ b/crates/ironrdp-svc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-svc" -version = "0.4.1" +version = "0.5.0" readme = "README.md" description = "IronRDP traits to implement RDP static virtual channels" edition.workspace = true @@ -21,7 +21,7 @@ std = [] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["alloc", "std"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", features = ["alloc", "std"] } # public bitflags = "2.9" [lints] diff --git a/crates/ironrdp-tls/CHANGELOG.md b/crates/ironrdp-tls/CHANGELOG.md index ec3ebe9e2b..455742e709 100644 --- a/crates/ironrdp-tls/CHANGELOG.md +++ b/crates/ironrdp-tls/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.1.3...ironrdp-tls-v0.1.4)] - 2025-08-29 + +### Build + +- Bump tokio from 1.46.1 to 1.47.0 (#893) ([5d513dcf09](https://github.com/Devolutions/IronRDP/commit/5d513dcf099505d4d52fe25884dc019590bc751e)) + ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.1.1...ironrdp-tls-v0.1.2)] - 2025-01-28 ### Documentation @@ -16,8 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump tokio from 1.42.0 to 1.43.0 (#650) ([ff6c6e875b](https://github.com/Devolutions/IronRDP/commit/ff6c6e875b4c2dce7ec109c3721739f86a808a31)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.1.0...ironrdp-tls-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-tls/Cargo.toml b/crates/ironrdp-tls/Cargo.toml index ca6cfb5440..55556ffddb 100644 --- a/crates/ironrdp-tls/Cargo.toml +++ b/crates/ironrdp-tls/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-tls" -version = "0.1.3" +version = "0.1.4" readme = "README.md" description = "TLS boilerplate common with most IronRDP clients" edition.workspace = true diff --git a/crates/ironrdp-tokio/Cargo.toml b/crates/ironrdp-tokio/Cargo.toml index 4a25ef2d9e..e129ac44cd 100644 --- a/crates/ironrdp-tokio/Cargo.toml +++ b/crates/ironrdp-tokio/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-tokio" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "`Framed*` traits implementation above Tokio’s traits" edition.workspace = true @@ -23,8 +23,8 @@ reqwest-native-tls = ["reqwest", "reqwest?/native-tls"] [dependencies] bytes = "1" -ironrdp-async = { path = "../ironrdp-async", version = "0.6" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6", optional = true } +ironrdp-async = { path = "../ironrdp-async", version = "0.7" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.7", optional = true } tokio = { version = "1", features = ["io-util"] } reqwest = { version = "0.12", default-features = false, features = ["http2", "system-proxy"], optional = true } sspi = { version = "0.16", features = ["network_client", "dns_resolver"], optional = true } diff --git a/crates/ironrdp/CHANGELOG.md b/crates/ironrdp/CHANGELOG.md index b0d0902fde..6323438adf 100644 --- a/crates/ironrdp/CHANGELOG.md +++ b/crates/ironrdp/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.12.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.11.0...ironrdp-v0.12.0)] - 2025-08-29 + +### Build + +- Update dependencies + ## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.10.0...ironrdp-v0.11.0)] - 2025-07-08 ### Build diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index 8598b4640d..839630fcd0 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp" -version = "0.11.0" +version = "0.12.0" readme = "README.md" description = "A meta crate re-exporting IronRDP crates for convenience" edition.workspace = true @@ -39,23 +39,23 @@ __bench = ["ironrdp-server/__bench"] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1", optional = true } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", optional = true } # public -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.3", optional = true } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6", optional = true } # public -ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.6", optional = true } # public -ironrdp-session = { path = "../ironrdp-session", version = "0.5", optional = true } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.4", optional = true } # public -ironrdp-input = { path = "../ironrdp-input", version = "0.3", optional = true } # public -ironrdp-server = { path = "../ironrdp-server", version = "0.7", optional = true, features = ["helper"] } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4", optional = true } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3", optional = true } # public -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.3", optional = true } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.5", optional = true } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.3", optional = true } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", optional = true } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.4", optional = true } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.7", optional = true } # public +ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.7", optional = true } # public +ironrdp-session = { path = "../ironrdp-session", version = "0.6", optional = true } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.5", optional = true } # public +ironrdp-input = { path = "../ironrdp-input", version = "0.4", optional = true } # public +ironrdp-server = { path = "../ironrdp-server", version = "0.8", optional = true, features = ["helper"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.5", optional = true } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.4", optional = true } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.4", optional = true } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.6", optional = true } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.4", optional = true } # public [dev-dependencies] -ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.6.0" } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.3.0" } +ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.7.0" } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.4.0" } anyhow = "1" async-trait = "0.1" image = { version = "0.25.6", default-features = false, features = ["png"] } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 8211bebd70..ea2782ee96 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -69,9 +69,9 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bit_field" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" @@ -81,9 +81,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" [[package]] name = "bitvec" @@ -114,9 +114,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.29" +version = "1.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" dependencies = [ "jobserver", "libc", @@ -125,9 +125,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "const-oid" @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -201,9 +201,9 @@ dependencies = [ [[package]] name = "derive_arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", @@ -286,9 +286,9 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr" -version = "0.3.0" +version = "0.4.0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.3", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -312,7 +312,7 @@ dependencies = [ [[package]] name = "ironrdp-displaycontrol" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -323,7 +323,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.3.1" +version = "0.4.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -362,10 +362,10 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.4.1" +version = "0.5.0" dependencies = [ "bit_field", - "bitflags 2.9.1", + "bitflags 2.9.3", "bitvec", "byteorder", "ironrdp-core", @@ -378,10 +378,10 @@ dependencies = [ [[package]] name = "ironrdp-pdu" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bit_field", - "bitflags 2.9.1", + "bitflags 2.9.3", "byteorder", "der-parser", "ironrdp-core", @@ -400,9 +400,9 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr" -version = "0.3.0" +version = "0.4.0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.3", "ironrdp-core", "ironrdp-error", "ironrdp-pdu", @@ -412,9 +412,9 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.5.0" +version = "0.6.0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.3", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -423,18 +423,18 @@ dependencies = [ [[package]] name = "ironrdp-svc" -version = "0.4.1" +version = "0.5.0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.3", "ironrdp-core", "ironrdp-pdu", ] [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom", "libc", @@ -448,9 +448,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libfuzzer-sys" @@ -586,9 +586,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -664,9 +664,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -783,21 +783,18 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.3+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" [[package]] name = "wyz" From 23c0cc2c365159d24330a89ec4015121b67bccb6 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:10:22 +0300 Subject: [PATCH 004/325] feat(web)!: extend `DeviceEvent.wheelRotations` event to support passing rotation units other than pixels (#952) --- crates/iron-remote-desktop/src/input.rs | 11 +++++++++- crates/iron-remote-desktop/src/lib.rs | 7 ++++--- crates/ironrdp-web/src/input.rs | 18 +++++++++++++++-- .../src/interfaces/DeviceEvent.ts | 6 ++++++ .../src/interfaces/RemoteDesktopModule.ts | 3 ++- .../src/services/remote-desktop.service.ts | 20 ++++++++++++++++++- 6 files changed, 57 insertions(+), 8 deletions(-) diff --git a/crates/iron-remote-desktop/src/input.rs b/crates/iron-remote-desktop/src/input.rs index 1ce86b4adf..a4b451358f 100644 --- a/crates/iron-remote-desktop/src/input.rs +++ b/crates/iron-remote-desktop/src/input.rs @@ -1,3 +1,12 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub enum RotationUnit { + Pixel, + Line, + Page, +} + pub trait DeviceEvent { fn mouse_button_pressed(button: u8) -> Self; @@ -5,7 +14,7 @@ pub trait DeviceEvent { fn mouse_move(x: u16, y: u16) -> Self; - fn wheel_rotations(vertical: bool, rotation_units: i16) -> Self; + fn wheel_rotations(vertical: bool, rotation_amount: i16, rotation_unit: RotationUnit) -> Self; fn key_pressed(scancode: u16) -> Self; diff --git a/crates/iron-remote-desktop/src/lib.rs b/crates/iron-remote-desktop/src/lib.rs index 6383f64218..48367f946f 100644 --- a/crates/iron-remote-desktop/src/lib.rs +++ b/crates/iron-remote-desktop/src/lib.rs @@ -14,7 +14,7 @@ pub use cursor::CursorStyle; pub use desktop_size::DesktopSize; pub use error::{IronError, IronErrorKind}; pub use extension::Extension; -pub use input::{DeviceEvent, InputTransaction}; +pub use input::{DeviceEvent, InputTransaction, RotationUnit}; pub use session::{Session, SessionBuilder, SessionTerminationInfo}; pub trait RemoteDesktopApi { @@ -329,11 +329,12 @@ macro_rules! make_bridge { } #[wasm_bindgen(js_name = wheelRotations)] - pub fn wheel_rotations(vertical: bool, rotation_units: i16) -> Self { + pub fn wheel_rotations(vertical: bool, rotation_amount: i16, rotation_unit: $crate::RotationUnit) -> Self { Self( <<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::wheel_rotations( vertical, - rotation_units, + rotation_amount, + rotation_unit, ), ) } diff --git a/crates/ironrdp-web/src/input.rs b/crates/ironrdp-web/src/input.rs index f22b5fec52..c6f1ea7ecc 100644 --- a/crates/ironrdp-web/src/input.rs +++ b/crates/ironrdp-web/src/input.rs @@ -1,3 +1,4 @@ +use iron_remote_desktop::RotationUnit; use ironrdp::input::{MouseButton, MousePosition, Operation, Scancode, WheelRotations}; use smallvec::SmallVec; use tracing::warn; @@ -30,10 +31,23 @@ impl iron_remote_desktop::DeviceEvent for DeviceEvent { Self(Operation::MouseMove(MousePosition { x, y })) } - fn wheel_rotations(vertical: bool, rotation_units: i16) -> Self { + fn wheel_rotations(vertical: bool, rotation_amount: i16, rotation_unit: RotationUnit) -> Self { + const LINES_TO_PIXELS_SCALE: i16 = 50; + const PAGES_TO_LINES_SCALE: i16 = 38; + + let lines_to_pixels = |lines: i16| lines * LINES_TO_PIXELS_SCALE; + + let pages_to_pixels = |pages: i16| pages * PAGES_TO_LINES_SCALE * LINES_TO_PIXELS_SCALE; + + let rotation_amount = match rotation_unit { + RotationUnit::Pixel => rotation_amount, + RotationUnit::Line => lines_to_pixels(rotation_amount), + RotationUnit::Page => pages_to_pixels(rotation_amount), + }; + Self(Operation::WheelRotations(WheelRotations { is_vertical: vertical, - rotation_units, + rotation_units: rotation_amount, })) } diff --git a/web-client/iron-remote-desktop/src/interfaces/DeviceEvent.ts b/web-client/iron-remote-desktop/src/interfaces/DeviceEvent.ts index cd1f7ce522..5805b57da8 100644 --- a/web-client/iron-remote-desktop/src/interfaces/DeviceEvent.ts +++ b/web-client/iron-remote-desktop/src/interfaces/DeviceEvent.ts @@ -1 +1,7 @@ +export enum RotationUnit { + Pixel = 0, + Line = 1, + Page = 2, +} + export type DeviceEvent = unknown; diff --git a/web-client/iron-remote-desktop/src/interfaces/RemoteDesktopModule.ts b/web-client/iron-remote-desktop/src/interfaces/RemoteDesktopModule.ts index 7129f992aa..7925e41a27 100644 --- a/web-client/iron-remote-desktop/src/interfaces/RemoteDesktopModule.ts +++ b/web-client/iron-remote-desktop/src/interfaces/RemoteDesktopModule.ts @@ -3,6 +3,7 @@ import type { DeviceEvent } from './DeviceEvent'; import type { InputTransaction } from './InputTransaction'; import type { SessionBuilder } from './SessionBuilder'; import type { ClipboardData } from './ClipboardData'; +import type { RotationUnit } from './DeviceEvent.ts'; export interface RemoteDesktopModule { DesktopSize: { new (width: number, height: number): DesktopSize }; @@ -13,7 +14,7 @@ export interface RemoteDesktopModule { mouseButtonPressed(button: number): DeviceEvent; mouseButtonReleased(button: number): DeviceEvent; mouseMove(x: number, y: number): DeviceEvent; - wheelRotations(vertical: boolean, rotationUnits: number): DeviceEvent; + wheelRotations(vertical: boolean, rotation_amount: number, rotation_unit: RotationUnit): DeviceEvent; keyPressed(scancode: number): DeviceEvent; keyReleased(scancode: number): DeviceEvent; unicodePressed(unicode: string): DeviceEvent; diff --git a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts index ea9ffae617..710fb81955 100644 --- a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts +++ b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts @@ -11,6 +11,7 @@ import type { MousePosition } from '../interfaces/MousePosition'; import type { IronError, IronErrorKind, SessionEvent } from '../interfaces/session-event'; import type { ClipboardData } from '../interfaces/ClipboardData'; import type { Session } from '../interfaces/Session'; +import { RotationUnit } from '../interfaces/DeviceEvent'; import type { DeviceEvent } from '../interfaces/DeviceEvent'; import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; import { ConfigBuilder } from './ConfigBuilder'; @@ -224,10 +225,27 @@ export class RemoteDesktopService { } } + rotation_unit_from_wheel_event(event: WheelEvent): RotationUnit { + switch (event.deltaMode) { + case event.DOM_DELTA_PIXEL: + return RotationUnit.Pixel; + case event.DOM_DELTA_LINE: + return RotationUnit.Line; + case event.DOM_DELTA_PAGE: + return RotationUnit.Page; + default: + return RotationUnit.Pixel; + } + } + mouseWheel(event: WheelEvent) { const vertical = event.deltaY !== 0; const rotation = vertical ? event.deltaY : event.deltaX; - this.doTransactionFromDeviceEvents([this.module.DeviceEvent.wheelRotations(vertical, -rotation)]); + const rotation_unit = this.rotation_unit_from_wheel_event(event); + + this.doTransactionFromDeviceEvents([ + this.module.DeviceEvent.wheelRotations(vertical, -rotation, rotation_unit), + ]); } setVisibility(state: boolean) { From 6de7f4bf601d80e7fa66142888b71b9218ce78d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Fri, 29 Aug 2025 10:38:50 -0400 Subject: [PATCH 005/325] fix(dvc-pipe-proxy): enable missing "fs" feature for tokio (#954) Changelog: ignore --- crates/ironrdp-dvc-pipe-proxy/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml index 93d99e7b88..240421c266 100644 --- a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml +++ b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml @@ -22,7 +22,7 @@ ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.4" } ironrdp-svc = { path = "../ironrdp-svc", version = "0.5" } # public (SvcMessage type) tracing = { version = "0.1", features = ["log"] } -tokio = { version = "1", features = ["net", "rt", "sync", "macros", "io-util"]} +tokio = { version = "1", features = ["net", "rt", "sync", "macros", "io-util", "fs"]} async-trait = "0.1" [lints] From 7f57e12fabb2e0d4608a3acc0be1ea70c6afe215 Mon Sep 17 00:00:00 2001 From: devolutionsbot <31221910+devolutionsbot@users.noreply.github.com> Date: Fri, 29 Aug 2025 10:40:26 -0400 Subject: [PATCH 006/325] chore(release): prepare for publishing (#953) --- Cargo.lock | 2 +- crates/iron-remote-desktop/CHANGELOG.md | 6 ++++++ crates/iron-remote-desktop/Cargo.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64f01fc627..460f8ea04d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2312,7 +2312,7 @@ dependencies = [ [[package]] name = "iron-remote-desktop" -version = "0.5.0" +version = "0.6.0" dependencies = [ "console_error_panic_hook", "tracing", diff --git a/crates/iron-remote-desktop/CHANGELOG.md b/crates/iron-remote-desktop/CHANGELOG.md index eb96e22bf0..fa04f3b369 100644 --- a/crates/iron-remote-desktop/CHANGELOG.md +++ b/crates/iron-remote-desktop/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.5.0...iron-remote-desktop-v0.6.0)] - 2025-08-29 + +### Features + +- [**breaking**] Extend `DeviceEvent.wheelRotations` event to support passing rotation units other than pixels (#952) ([23c0cc2c36](https://github.com/Devolutions/IronRDP/commit/23c0cc2c365159d24330a89ec4015121b67bccb6)) + ## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.4.0...iron-remote-desktop-v0.5.0)] - 2025-08-29 ### Bug Fixes diff --git a/crates/iron-remote-desktop/Cargo.toml b/crates/iron-remote-desktop/Cargo.toml index 4ae338e209..7c08278284 100644 --- a/crates/iron-remote-desktop/Cargo.toml +++ b/crates/iron-remote-desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iron-remote-desktop" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "Helper crate for building WASM modules compatible with iron-remote-desktop WebComponent" edition.workspace = true From d31291362c011303d225a1377326b8bd7cd9073e Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:46:10 +0300 Subject: [PATCH 007/325] style(web): follow-up to PR #935 (#955) --- .../iron-remote-desktop/src/services/clipboard.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web-client/iron-remote-desktop/src/services/clipboard.service.ts b/web-client/iron-remote-desktop/src/services/clipboard.service.ts index 88e155806f..f37df4ef18 100644 --- a/web-client/iron-remote-desktop/src/services/clipboard.service.ts +++ b/web-client/iron-remote-desktop/src/services/clipboard.service.ts @@ -6,7 +6,7 @@ import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; import { runWhenFocusedQueue } from '../lib/stores/runWhenFocusedStore'; import { SessionEventType } from '../enums/SessionEventType'; -const CLIPBOARD_MONITORING_INTERVAL = 100; // ms +const CLIPBOARD_MONITORING_INTERVAL_MS = 100; export class ClipboardService { private remoteDesktopService: RemoteDesktopService; @@ -40,7 +40,7 @@ export class ClipboardService { if (this.remoteDesktopService.autoClipboard) { this.remoteDesktopService.setOnRemoteClipboardChanged(this.onRemoteClipboardChangedAutoMode.bind(this)); // Start the clipboard monitoring loop - setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL); + setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL_MS); } else { this.remoteDesktopService.setOnRemoteClipboardChanged(this.onRemoteClipboardChangedManualMode.bind(this)); } @@ -305,7 +305,7 @@ export class ClipboardService { } } finally { if (!get(isComponentDestroyed)) { - setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL); + setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL_MS); } } } From 6b626d4fca4d316608dd3aaf5b9aa1fff82e3442 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Mon, 1 Sep 2025 12:46:24 +0300 Subject: [PATCH 008/325] fix(web): implement text-only clipboard for outdated versions of Firefox browser (#951) Firefox versions before v127 do not support the write() method - only writeText(). Therefore, the Extended Clipboard checkbox must be automatically disabled. --- .../src/enums/ClipboardApiSupported.ts | 10 ++ .../src/enums/SessionEventType.ts | 1 + .../src/services/clipboard.service.ts | 155 +++++++++++++++++- 3 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 web-client/iron-remote-desktop/src/enums/ClipboardApiSupported.ts diff --git a/web-client/iron-remote-desktop/src/enums/ClipboardApiSupported.ts b/web-client/iron-remote-desktop/src/enums/ClipboardApiSupported.ts new file mode 100644 index 0000000000..7ba9b9195d --- /dev/null +++ b/web-client/iron-remote-desktop/src/enums/ClipboardApiSupported.ts @@ -0,0 +1,10 @@ +export enum ClipboardApiSupported { + // Full clipboard API support (read and write text and images) + Full, + // Text-only support (Firefox v125-v126) + TextOnly, + // Text-only support, but only writing data received from the server (Firefox < v125) + TextOnlyServerOnly, + // Clipboard API is not supported at all + None, +} diff --git a/web-client/iron-remote-desktop/src/enums/SessionEventType.ts b/web-client/iron-remote-desktop/src/enums/SessionEventType.ts index 1a351001d6..5ade51a0e9 100644 --- a/web-client/iron-remote-desktop/src/enums/SessionEventType.ts +++ b/web-client/iron-remote-desktop/src/enums/SessionEventType.ts @@ -2,6 +2,7 @@ STARTED, TERMINATED, ERROR, + WARNING, // Clipboard events CLIPBOARD_REMOTE_UPDATE, diff --git a/web-client/iron-remote-desktop/src/services/clipboard.service.ts b/web-client/iron-remote-desktop/src/services/clipboard.service.ts index f37df4ef18..b7efa6f0af 100644 --- a/web-client/iron-remote-desktop/src/services/clipboard.service.ts +++ b/web-client/iron-remote-desktop/src/services/clipboard.service.ts @@ -5,6 +5,7 @@ import type { ClipboardData } from '../interfaces/ClipboardData'; import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; import { runWhenFocusedQueue } from '../lib/stores/runWhenFocusedStore'; import { SessionEventType } from '../enums/SessionEventType'; +import { ClipboardApiSupported } from '../enums/ClipboardApiSupported'; const CLIPBOARD_MONITORING_INTERVAL_MS = 100; @@ -12,7 +13,7 @@ export class ClipboardService { private remoteDesktopService: RemoteDesktopService; private module: RemoteDesktopModule; - private isClipboardApiSupported: boolean = false; + private ClipboardApiSupported: ClipboardApiSupported = ClipboardApiSupported.None; private lastClientClipboardItems: Record = {}; private lastReceivedClipboardData: Record = {}; @@ -29,26 +30,56 @@ export class ClipboardService { // Detect if browser supports async Clipboard API if (navigator.clipboard != undefined) { if (navigator.clipboard.read != undefined && navigator.clipboard.write != undefined) { - this.isClipboardApiSupported = true; + this.ClipboardApiSupported = ClipboardApiSupported.Full; + } else if (navigator.clipboard.readText != undefined) { + this.ClipboardApiSupported = ClipboardApiSupported.TextOnly; + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.WARNING, + data: 'Clipboard is limited to text-only data types due to an outdated browser version!', + }); + } else if (navigator.clipboard.writeText != undefined) { + this.ClipboardApiSupported = ClipboardApiSupported.TextOnlyServerOnly; + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.WARNING, + data: 'Clipboard reading is not supported and writing is limited to text-only data types due to an outdated browser version!', + }); } } - if (!this.isClipboardApiSupported) return; + // The basic Clipboard API is widely supported in modern browsers, + // so this condition should never be true in practice. + if (this.ClipboardApiSupported === ClipboardApiSupported.None) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.WARNING, + data: 'Clipboard is not supported due to an outdated browser version!', + }); + return; + } this.remoteDesktopService.setOnForceClipboardUpdate(this.onForceClipboardUpdate.bind(this)); - if (this.remoteDesktopService.autoClipboard) { - this.remoteDesktopService.setOnRemoteClipboardChanged(this.onRemoteClipboardChangedAutoMode.bind(this)); - // Start the clipboard monitoring loop - setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL_MS); + if (this.ClipboardApiSupported === ClipboardApiSupported.Full) { + if (this.remoteDesktopService.autoClipboard) { + this.remoteDesktopService.setOnRemoteClipboardChanged(this.onRemoteClipboardChangedAutoMode.bind(this)); + // Start the clipboard monitoring loop + setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL_MS); + } else { + this.remoteDesktopService.setOnRemoteClipboardChanged( + this.onRemoteClipboardChangedManualMode.bind(this), + ); + } } else { - this.remoteDesktopService.setOnRemoteClipboardChanged(this.onRemoteClipboardChangedManualMode.bind(this)); + this.remoteDesktopService.setOnRemoteClipboardChanged(this.ffOnRemoteClipboardChanged.bind(this)); } } // Copies clipboard content received from the server to the local clipboard. // Returns the result of the operation. On failure, it additionally raises an error session event. async saveRemoteClipboardData(): Promise { + if (this.ClipboardApiSupported !== ClipboardApiSupported.Full) { + return await this.ffSaveRemoteClipboardData(); + } + if (this.clipboardDataToSave == null) { this.remoteDesktopService.raiseSessionEvent({ type: SessionEventType.ERROR, @@ -76,6 +107,10 @@ export class ClipboardService { // Sends local clipboard's content to the server. // Returns the result of the operation. On failure, it additionally raises an error session event. async sendClipboardData(): Promise { + if (this.ClipboardApiSupported !== ClipboardApiSupported.Full) { + return await this.ffSendClipboardData(); + } + try { const value = await navigator.clipboard.read(); @@ -309,4 +344,108 @@ export class ClipboardService { } } } + + // Firefox v126 and below does not support `navigator.clipboard.read` and `navigator.clipboard.write`. + // So, we need to define specific methods to handle text-only clipboard. + // + // Also, Firefox v124 and below does not support `navigator.clipboard.readText`. + // Because of this, we cannot read the data from the clipboard at all. + + private ffClipboardDataToSave: string | null = null; + + // This function is required to retrieve the text data from the `ClipboardData`. + private ffRetrieveTextData(data: ClipboardData): string { + for (const item of data.items()) { + if (item.mimeType().startsWith('text/')) { + const value = item.value(); + if (typeof value === 'string') return value; + } + } + + return ''; + } + + // Firefox specific function. + // This callback is required to update client clipboard state when remote side has changed. + private ffOnRemoteClipboardChanged(data: ClipboardData) { + const value = this.ffRetrieveTextData(data); + // Non-text clipboard data is ignored. + if (value === '') return; + + this.ffClipboardDataToSave = value; + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.CLIPBOARD_REMOTE_UPDATE, + data: '', + }); + } + + // Firefox specific function. We are using text-only clipboard API here. + // + // Copies clipboard content received from the server to the local clipboard. + // Returns the result of the operation. On failure, it additionally raises an error session event. + private async ffSaveRemoteClipboardData(): Promise { + if (this.ffClipboardDataToSave == null) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'The server did not send the clipboard data.', + }); + return false; + } + + try { + await navigator.clipboard.writeText(this.ffClipboardDataToSave); + this.ffClipboardDataToSave = null; + return true; + } catch (err) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'Failed to write to the clipboard: ' + err, + }); + return false; + } + } + + // Firefox specific function. We are using text-only clipboard API here. + // + // Sends local clipboard's content to the server. + // Returns the result of the operation. On failure, it additionally raises an error session event. + private async ffSendClipboardData(): Promise { + if (this.ClipboardApiSupported !== ClipboardApiSupported.TextOnly) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'The browser does not support clipboard read.', + }); + return false; + } + + try { + const value = await navigator.clipboard.readText(); + + // Clipboard is empty + if (value.length == 0) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'The clipboard has no data.', + }); + return false; + } + + const clipboardData = new this.module.ClipboardData(); + clipboardData.addText('text/plain', value); + + if (!clipboardData.isEmpty()) { + this.lastSentClipboardData = clipboardData; + // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. + await this.remoteDesktopService.onClipboardChanged(clipboardData); + } + + return true; + } catch (err) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.ERROR, + data: 'Failed to read from the clipboard: ' + err, + }); + return false; + } + } } From cd2f25f97a3d67b0a9d6b511078f6c18aea67625 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:55:27 +0300 Subject: [PATCH 009/325] chore(release): release `iron-remote-desktop` 0.9.0 (#957) --- web-client/iron-remote-desktop/public/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-client/iron-remote-desktop/public/package.json b/web-client/iron-remote-desktop/public/package.json index abe9b0954e..51bb7f5a60 100644 --- a/web-client/iron-remote-desktop/public/package.json +++ b/web-client/iron-remote-desktop/public/package.json @@ -10,7 +10,7 @@ "Alexandr Yusuk" ], "description": "Backend-agnostic Web Component for remote desktop protocols", - "version": "0.8.0", + "version": "0.9.0", "main": "iron-remote-desktop.js", "types": "index.d.ts", "files": [ From 94ca3e25a88d4362f98d25eaa96d123958c3dded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Mon, 1 Sep 2025 13:49:09 -0400 Subject: [PATCH 010/325] refactor: add a FIXME related to FastPathUpdate handling (#958) --- crates/ironrdp-pdu/src/basic_output/fast_path.rs | 2 +- crates/ironrdp-session/src/fast_path.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-pdu/src/basic_output/fast_path.rs b/crates/ironrdp-pdu/src/basic_output/fast_path.rs index 12a5582a3e..236bfdc05e 100644 --- a/crates/ironrdp-pdu/src/basic_output/fast_path.rs +++ b/crates/ironrdp-pdu/src/basic_output/fast_path.rs @@ -251,7 +251,7 @@ impl<'a> FastPathUpdate<'a> { UpdateCode::CachedPointer => Ok(Self::Pointer(PointerUpdateData::Cached(decode_cursor(src)?))), UpdateCode::NewPointer => Ok(Self::Pointer(PointerUpdateData::New(decode_cursor(src)?))), UpdateCode::LargePointer => Ok(Self::Pointer(PointerUpdateData::Large(decode_cursor(src)?))), - _ => Err(invalid_field_err!("updateCode", "Invalid fast path update code")), + _ => Err(invalid_field_err!("updateCode", "unsupported fast-path update code")), } } diff --git a/crates/ironrdp-session/src/fast_path.rs b/crates/ironrdp-session/src/fast_path.rs index 0a8811b731..8dbdc06823 100644 --- a/crates/ironrdp-session/src/fast_path.rs +++ b/crates/ironrdp-session/src/fast_path.rs @@ -295,6 +295,9 @@ impl Processor { }; } Err(e) => { + // FIXME: This seems to be a way of special-handling the error case in FastPathUpdate::decode_cursor_with_code + // to ignore the unsupported update PDUs, but this is a fragile logic and the rationale behind it is not + // obvious. if let DecodeErrorKind::InvalidField { field, reason } = e.kind { warn!(field, reason, "Received invalid Fast-Path update"); processor_updates.push(UpdateKind::None); From 729ecf965e3314eb4a766b153f384db34c75472e Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:15:43 +0300 Subject: [PATCH 011/325] feat(web): add warning for clipboard unavailability in non-secure context (#959) --- .../src/services/clipboard.service.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web-client/iron-remote-desktop/src/services/clipboard.service.ts b/web-client/iron-remote-desktop/src/services/clipboard.service.ts index b7efa6f0af..0c1bb87886 100644 --- a/web-client/iron-remote-desktop/src/services/clipboard.service.ts +++ b/web-client/iron-remote-desktop/src/services/clipboard.service.ts @@ -27,6 +27,15 @@ export class ClipboardService { } initClipboard() { + // Clipboard API is available only in secure contexts (HTTPS). + if (!window.isSecureContext) { + this.remoteDesktopService.raiseSessionEvent({ + type: SessionEventType.WARNING, + data: 'Clipboard is available only in secure contexts (HTTPS).', + }); + return; + } + // Detect if browser supports async Clipboard API if (navigator.clipboard != undefined) { if (navigator.clipboard.read != undefined && navigator.clipboard.write != undefined) { From 50574c570f6e44d264153337e5f87a5313f190e6 Mon Sep 17 00:00:00 2001 From: rhammonds-teleport Date: Tue, 2 Sep 2025 10:41:41 -0400 Subject: [PATCH 012/325] feat(rdpdr): support device removal (#947) --- crates/ironrdp-rdpdr/src/lib.rs | 13 +++++-- crates/ironrdp-rdpdr/src/pdu/efs.rs | 56 +++++++++++++++++++++++++++++ crates/ironrdp-rdpdr/src/pdu/mod.rs | 20 ++++++++--- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/ironrdp-rdpdr/src/lib.rs b/crates/ironrdp-rdpdr/src/lib.rs index b6cad92073..48062bf68e 100644 --- a/crates/ironrdp-rdpdr/src/lib.rs +++ b/crates/ironrdp-rdpdr/src/lib.rs @@ -11,9 +11,9 @@ use ironrdp_pdu::gcc::ChannelName; use ironrdp_pdu::{decode_err, pdu_other_err, PduResult}; use ironrdp_svc::{CompressionCondition, SvcClientProcessor, SvcMessage, SvcProcessor}; use pdu::efs::{ - Capabilities, ClientDeviceListAnnounce, ClientNameRequest, ClientNameRequestUnicodeFlag, CoreCapability, - CoreCapabilityKind, DeviceControlRequest, DeviceIoRequest, DeviceType, Devices, ServerDeviceAnnounceResponse, - VersionAndIdPdu, VersionAndIdPduKind, + Capabilities, ClientDeviceListAnnounce, ClientDeviceListRemove, ClientNameRequest, ClientNameRequestUnicodeFlag, + CoreCapability, CoreCapabilityKind, DeviceControlRequest, DeviceIoRequest, DeviceType, Devices, + ServerDeviceAnnounceResponse, VersionAndIdPdu, VersionAndIdPduKind, }; use pdu::esc::{ScardCall, ScardIoCtlCode}; use pdu::RdpdrPdu; @@ -94,6 +94,12 @@ impl Rdpdr { ClientDeviceListAnnounce::new_drive(device_id, name) } + pub fn remove_device(&mut self, device_id: u32) -> Option { + Some(ClientDeviceListRemove::remove_device( + self.device_list.remove_device(device_id)?, + )) + } + pub fn downcast_backend(&self) -> Option<&T> { self.backend.as_any().downcast_ref::() } @@ -210,6 +216,7 @@ impl SvcProcessor for Rdpdr { // to make sure we don't miss handling new RdpdrPdu variants here during active development. RdpdrPdu::ClientNameRequest(_) | RdpdrPdu::ClientDeviceListAnnounce(_) + | RdpdrPdu::ClientDeviceListRemove(_) | RdpdrPdu::VersionAndIdPdu(_) | RdpdrPdu::CoreCapability(_) | RdpdrPdu::DeviceControlResponse(_) diff --git a/crates/ironrdp-rdpdr/src/pdu/efs.rs b/crates/ironrdp-rdpdr/src/pdu/efs.rs index bfde792bc1..5edd7f33f5 100644 --- a/crates/ironrdp-rdpdr/src/pdu/efs.rs +++ b/crates/ironrdp-rdpdr/src/pdu/efs.rs @@ -782,6 +782,46 @@ impl ClientDeviceListAnnounce { } } +/// [2.2.3.2] Client Device List Remove (DR_DEVICELIST_REMOVE) +/// +/// [2.2.3.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/13bd4c0a-e674-47a5-b317-50a835defb55 +#[derive(Debug, PartialEq, Clone)] +pub struct ClientDeviceListRemove { + pub device_list: Vec, +} + +impl ClientDeviceListRemove { + const FIXED_PART_SIZE: usize = size_of::(); // DeviceCount + + pub(crate) fn remove_device(device_id: u32) -> Self { + Self { + device_list: vec![device_id], + } + } + + pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + dst.write_u32(cast_length!( + "ClientDeviceListRemove", + "DeviceCount", + self.device_list.len() + )?); + + for dev in self.device_list.iter() { + dst.write_u32(*dev) + } + + Ok(()) + } + + pub fn name(&self) -> &'static str { + "DR_DEVICELIST_REMOVE" + } + + pub fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.device_list.len() * size_of::() + } +} + #[derive(Debug, PartialEq, Clone)] pub struct Devices(Vec); @@ -798,6 +838,10 @@ impl Devices { self.push(DeviceAnnounceHeader::new_drive(device_id, name)); } + pub fn remove_device(&mut self, device_id: u32) -> Option { + self.remove(device_id) + } + /// Returns the [`DeviceType`] for the given device ID. pub fn for_device_type(&self, device_id: u32) -> DecodeResult { if let Some(device_type) = self.0.iter().find(|d| d.device_id == device_id).map(|d| d.device_type) { @@ -815,6 +859,18 @@ impl Devices { self.0.push(device); } + fn remove(&mut self, device: u32) -> Option { + Some( + self.0 + .remove( + self.0 + .iter() + .position(|d: &DeviceAnnounceHeader| d.device_id == device)?, + ) + .device_id, + ) + } + pub fn clone_inner(&mut self) -> Vec { self.0.clone() } diff --git a/crates/ironrdp-rdpdr/src/pdu/mod.rs b/crates/ironrdp-rdpdr/src/pdu/mod.rs index aa7446fcb6..fa96cba0b9 100644 --- a/crates/ironrdp-rdpdr/src/pdu/mod.rs +++ b/crates/ironrdp-rdpdr/src/pdu/mod.rs @@ -7,10 +7,11 @@ use ironrdp_core::{ use ironrdp_svc::SvcEncode; use self::efs::{ - ClientDeviceListAnnounce, ClientDriveQueryDirectoryResponse, ClientDriveQueryInformationResponse, - ClientDriveQueryVolumeInformationResponse, ClientDriveSetInformationResponse, ClientNameRequest, CoreCapability, - CoreCapabilityKind, DeviceCloseResponse, DeviceControlResponse, DeviceCreateResponse, DeviceIoRequest, - DeviceReadResponse, DeviceWriteResponse, ServerDeviceAnnounceResponse, VersionAndIdPdu, VersionAndIdPduKind, + ClientDeviceListAnnounce, ClientDeviceListRemove, ClientDriveQueryDirectoryResponse, + ClientDriveQueryInformationResponse, ClientDriveQueryVolumeInformationResponse, ClientDriveSetInformationResponse, + ClientNameRequest, CoreCapability, CoreCapabilityKind, DeviceCloseResponse, DeviceControlResponse, + DeviceCreateResponse, DeviceIoRequest, DeviceReadResponse, DeviceWriteResponse, ServerDeviceAnnounceResponse, + VersionAndIdPdu, VersionAndIdPduKind, }; pub mod efs; @@ -22,6 +23,7 @@ pub enum RdpdrPdu { ClientNameRequest(ClientNameRequest), CoreCapability(CoreCapability), ClientDeviceListAnnounce(ClientDeviceListAnnounce), + ClientDeviceListRemove(ClientDeviceListRemove), ServerDeviceAnnounceResponse(ServerDeviceAnnounceResponse), DeviceIoRequest(DeviceIoRequest), DeviceControlResponse(DeviceControlResponse), @@ -73,6 +75,10 @@ impl RdpdrPdu { component: Component::RdpdrCtypCore, packet_id: PacketId::CoreDevicelistAnnounce, }, + RdpdrPdu::ClientDeviceListRemove(_) => SharedHeader { + component: Component::RdpdrCtypCore, + packet_id: PacketId::CoreDevicelistRemove, + }, RdpdrPdu::ServerDeviceAnnounceResponse(_) => SharedHeader { component: Component::RdpdrCtypCore, packet_id: PacketId::CoreDeviceReply, @@ -132,6 +138,7 @@ impl Encode for RdpdrPdu { RdpdrPdu::ClientNameRequest(pdu) => pdu.encode(dst), RdpdrPdu::CoreCapability(pdu) => pdu.encode(dst), RdpdrPdu::ClientDeviceListAnnounce(pdu) => pdu.encode(dst), + RdpdrPdu::ClientDeviceListRemove(pdu) => pdu.encode(dst), RdpdrPdu::ServerDeviceAnnounceResponse(pdu) => pdu.encode(dst), RdpdrPdu::DeviceIoRequest(pdu) => pdu.encode(dst), RdpdrPdu::DeviceControlResponse(pdu) => pdu.encode(dst), @@ -158,6 +165,7 @@ impl Encode for RdpdrPdu { RdpdrPdu::ClientNameRequest(pdu) => pdu.name(), RdpdrPdu::CoreCapability(pdu) => pdu.name(), RdpdrPdu::ClientDeviceListAnnounce(pdu) => pdu.name(), + RdpdrPdu::ClientDeviceListRemove(pdu) => pdu.name(), RdpdrPdu::ServerDeviceAnnounceResponse(pdu) => pdu.name(), RdpdrPdu::DeviceIoRequest(pdu) => pdu.name(), RdpdrPdu::DeviceControlResponse(pdu) => pdu.name(), @@ -181,6 +189,7 @@ impl Encode for RdpdrPdu { RdpdrPdu::ClientNameRequest(pdu) => pdu.size(), RdpdrPdu::CoreCapability(pdu) => pdu.size(), RdpdrPdu::ClientDeviceListAnnounce(pdu) => pdu.size(), + RdpdrPdu::ClientDeviceListRemove(pdu) => pdu.size(), RdpdrPdu::ServerDeviceAnnounceResponse(pdu) => pdu.size(), RdpdrPdu::DeviceIoRequest(pdu) => pdu.size(), RdpdrPdu::DeviceControlResponse(pdu) => pdu.size(), @@ -215,6 +224,9 @@ impl fmt::Debug for RdpdrPdu { Self::ClientDeviceListAnnounce(it) => { write!(f, "RdpdrPdu({it:?})") } + Self::ClientDeviceListRemove(it) => { + write!(f, "RdpdrPdu({it:?})") + } Self::ServerDeviceAnnounceResponse(it) => { write!(f, "RdpdrPdu({it:?})") } From 598cd3f76e95b7833988e89fd11574cd05348af7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Sep 2025 20:03:18 -0400 Subject: [PATCH 013/325] build(deps): bump the patch group across 1 directory with 2 updates (#962) --- Cargo.lock | 65 ++++++++++++++++++------------------------------------ 1 file changed, 21 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 460f8ea04d..a7afd27b30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1177,9 +1177,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", ] @@ -3090,11 +3090,11 @@ dependencies = [ [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -3250,12 +3250,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -3729,12 +3728,6 @@ dependencies = [ "libredox", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -4174,7 +4167,7 @@ dependencies = [ "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.6", + "regex-syntax", "rusty-fork", "tempfile", "unarray", @@ -4409,17 +4402,8 @@ checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.10", - "regex-syntax 0.8.6", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] @@ -4430,15 +4414,9 @@ checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.6", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.6" @@ -5317,12 +5295,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "8ca967379f9d8eb8058d86ed467d81d03e81acd45757e4ca341c24affbe8e8e3" dependencies = [ "deranged", - "itoa", "js-sys", "num-conv", "powerfmt", @@ -5333,15 +5310,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "a9108bb380861b07264b950ded55a44a14a4adc68b9f5efd85aafc3aa4d40a68" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "7182799245a7264ce590b349d90338f1c1affad93d2639aed5f8f69c090b334c" dependencies = [ "num-conv", "time-core", @@ -5676,14 +5653,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", From 21fa028dffa5f9bb1498b4d48d063ea42929faf5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 06:58:50 +0000 Subject: [PATCH 014/325] build(deps): bump png from 0.17.16 to 0.18.0 (#961) --- Cargo.lock | 21 ++++++++++++---- crates/ironrdp-cliprdr-format/Cargo.toml | 2 +- crates/ironrdp-cliprdr-format/src/bitmap.rs | 8 +++++-- crates/ironrdp-testsuite-core/Cargo.toml | 2 +- .../tests/pdu/pointer.rs | 6 +++-- crates/ironrdp-web/Cargo.toml | 2 +- fuzz/Cargo.lock | 24 +++++++------------ 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7afd27b30..835718fc2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2231,7 +2231,7 @@ dependencies = [ "bytemuck", "byteorder-lite", "num-traits", - "png", + "png 0.17.16", ] [[package]] @@ -2473,7 +2473,7 @@ name = "ironrdp-cliprdr-format" version = "0.1.3" dependencies = [ "ironrdp-core", - "png", + "png 0.18.0", ] [[package]] @@ -2805,7 +2805,7 @@ dependencies = [ "ironrdp-session", "lazy_static", "paste", - "png", + "png 0.18.0", "pretty_assertions", "proptest", "rstest", @@ -2873,7 +2873,7 @@ dependencies = [ "ironrdp-rdcleanpath", "ironrdp-rdpfile", "js-sys", - "png", + "png 0.18.0", "resize", "rgb", "semver", @@ -4041,6 +4041,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.9.3", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.10.0" diff --git a/crates/ironrdp-cliprdr-format/Cargo.toml b/crates/ironrdp-cliprdr-format/Cargo.toml index f2bdcbf5d4..b979d0cecc 100644 --- a/crates/ironrdp-cliprdr-format/Cargo.toml +++ b/crates/ironrdp-cliprdr-format/Cargo.toml @@ -17,7 +17,7 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -png = "0.17" +png = "0.18" [lints] workspace = true diff --git a/crates/ironrdp-cliprdr-format/src/bitmap.rs b/crates/ironrdp-cliprdr-format/src/bitmap.rs index 497c6bb27d..5645d4ebbb 100644 --- a/crates/ironrdp-cliprdr-format/src/bitmap.rs +++ b/crates/ironrdp-cliprdr-format/src/bitmap.rs @@ -1,3 +1,5 @@ +use std::io::Cursor; + use ironrdp_core::{ cast_int, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, @@ -735,13 +737,15 @@ fn top_down_rgba_to_bottom_up_bgra( } fn decode_png(mut input: &[u8]) -> Result<(png::OutputInfo, Vec), BitmapError> { - let mut decoder = png::Decoder::new(&mut input); + let mut decoder = png::Decoder::new(Cursor::new(&mut input)); // We need to produce 32-bit DIB, so we should expand the palette to 32-bit RGBA. decoder.set_transformations(png::Transformations::ALPHA | png::Transformations::EXPAND); let mut reader = decoder.read_info()?; - let output_buffer_len = reader.output_buffer_size(); + let Some(output_buffer_len) = reader.output_buffer_size() else { + return Err(BitmapError::BufferTooBig); + }; // Prevent allocation of huge buffers. ensure(output_buffer_len <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?; diff --git a/crates/ironrdp-testsuite-core/Cargo.toml b/crates/ironrdp-testsuite-core/Cargo.toml index 96b0339340..217cc8a96d 100644 --- a/crates/ironrdp-testsuite-core/Cargo.toml +++ b/crates/ironrdp-testsuite-core/Cargo.toml @@ -47,7 +47,7 @@ ironrdp-rdpsnd.path = "../ironrdp-rdpsnd" ironrdp-session = { path = "../ironrdp-session", features = ["qoi"] } ironrdp-propertyset.path = "../ironrdp-propertyset" ironrdp-rdpfile.path = "../ironrdp-rdpfile" -png = "0.17" +png = "0.18" pretty_assertions = "1.4" proptest.workspace = true rstest.workspace = true diff --git a/crates/ironrdp-testsuite-core/tests/pdu/pointer.rs b/crates/ironrdp-testsuite-core/tests/pdu/pointer.rs index 68728b1595..1859d791de 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/pointer.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/pointer.rs @@ -1,3 +1,5 @@ +use std::io::Cursor; + use expect_test::expect; use ironrdp_graphics::pointer::{DecodedPointer, PointerBitmapTarget}; use ironrdp_pdu::pointer::{ @@ -27,8 +29,8 @@ fn expect_pointer_png(pointer: &DecodedPointer, expected_file_path: &str) { } let png_buffer = std::fs::read(path).unwrap(); - let mut png_reader = png::Decoder::new(&png_buffer[..]).read_info().unwrap(); - let mut png_reader_buffer = vec![0u8; png_reader.output_buffer_size()]; + let mut png_reader = png::Decoder::new(Cursor::new(&png_buffer[..])).read_info().unwrap(); + let mut png_reader_buffer = vec![0u8; png_reader.output_buffer_size().unwrap()]; let frame_size = png_reader.next_frame(&mut png_reader_buffer).unwrap().buffer_size(); let expected = &png_reader_buffer[..frame_size]; assert_eq!(expected, &pointer.bitmap_data); diff --git a/crates/ironrdp-web/Cargo.toml b/crates/ironrdp-web/Cargo.toml index 7f1a44e850..e990555356 100644 --- a/crates/ironrdp-web/Cargo.toml +++ b/crates/ironrdp-web/Cargo.toml @@ -54,7 +54,7 @@ gloo-timers = { version = "0.3", default-features = false, features = ["futures" # Rendering softbuffer = { version = "0.4", default-features = false } -png = "0.17" +png = "0.18" resize = { version = "0.8", features = ["std"], default-features = false } rgb = "0.8" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index ea2782ee96..de4d668ad6 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -73,12 +73,6 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.9.3" @@ -288,7 +282,7 @@ dependencies = [ name = "ironrdp-cliprdr" version = "0.4.0" dependencies = [ - "bitflags 2.9.3", + "bitflags", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -365,7 +359,7 @@ name = "ironrdp-graphics" version = "0.5.0" dependencies = [ "bit_field", - "bitflags 2.9.3", + "bitflags", "bitvec", "byteorder", "ironrdp-core", @@ -381,7 +375,7 @@ name = "ironrdp-pdu" version = "0.6.0" dependencies = [ "bit_field", - "bitflags 2.9.3", + "bitflags", "byteorder", "der-parser", "ironrdp-core", @@ -402,7 +396,7 @@ dependencies = [ name = "ironrdp-rdpdr" version = "0.4.0" dependencies = [ - "bitflags 2.9.3", + "bitflags", "ironrdp-core", "ironrdp-error", "ironrdp-pdu", @@ -414,7 +408,7 @@ dependencies = [ name = "ironrdp-rdpsnd" version = "0.6.0" dependencies = [ - "bitflags 2.9.3", + "bitflags", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -425,7 +419,7 @@ dependencies = [ name = "ironrdp-svc" version = "0.5.0" dependencies = [ - "bitflags 2.9.3", + "bitflags", "ironrdp-core", "ironrdp-pdu", ] @@ -573,11 +567,11 @@ dependencies = [ [[package]] name = "png" -version = "0.17.16" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", From 8cf9f3dda4d02fda24b5fe84dba20d8fcd02d84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Thu, 4 Sep 2025 12:06:19 -0400 Subject: [PATCH 015/325] ci(npm-publish): automatically push tags (#966) --- .github/workflows/npm-publish.yml | 75 ++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index d5f154851f..2a559f724a 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -106,6 +106,11 @@ jobs: - npm-merge steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Download NPM packages artifact uses: actions/download-artifact@v4 with: @@ -126,14 +131,35 @@ jobs: $files = Get-ChildItem -Recurse npm-packages/*.tgz foreach ($file in $files) { - Write-Host "Publishing $($File.Name)..." + Write-Host "Processing $($file.Name)..." + + $match = [regex]::Match($file.Name, '^(?.+)-(?\d+\.\d+\.\d+)\.tgz$') + + if (-not $match.Success) { + Write-Host "Unable to parse package name/version from $($file.Name), skipping." + continue + } + + $pkgName = $match.Groups['name'].Value + + # Normalize scope for npm lookups: "devolutions-foo" => "@devolutions/foo" + if ($pkgName -like 'devolutions-*') { + $scopedName = "@devolutions/$($pkgName.Substring(12))" + } else { + $scopedName = $pkgName + } + + $pkgVersion = $match.Groups['version'].Value + + # Check if this version exists on npm; exit code 0 means it does. + npm view "$scopedName@$pkgVersion" | Out-Null + + if ($LASTEXITCODE -eq 0) { + Write-Host "$scopedName@$pkgVersion already exists on npm; skipping publish." + continue + } - $publishCmd = @( - 'npm', - 'publish', - "$File", - '--access=public' - ) + $publishCmd = @('npm','publish',"$file",'--access=public') if ($isDryRun) { $publishCmd += '--dry-run' @@ -143,7 +169,42 @@ jobs: Invoke-Expression $publishCmd } + - name: Create version tags + if: ${{ needs.preflight.outputs.dry-run == 'false' }} + shell: bash + env: + GIT_AUTHOR_NAME: github-actions + GIT_AUTHOR_EMAIL: github-actions@github.com + GIT_COMMITTER_NAME: github-actions + GIT_COMMITTER_EMAIL: github-actions@github.com + run: | + set -e + + git fetch --tags + + for file in npm-packages/*.tgz; do + base=$(basename "$file" .tgz) + + # Split base at the last hyphen to separate name and version + pkg=${base%-*} + # Strip the unscoped prefix introduced by `npm pack` for @devolutions/. + pkg=${pkg#devolutions-} + + version=${base##*-} + + tag="npm-${pkg}-v${version}" + + if git rev-parse "$tag" >/dev/null 2>&1; then + echo "Tag $tag already exists; skipping." + continue + fi + + git tag "$tag" "$GITHUB_SHA" + git push origin "$tag" + done + - name: Update Artifactory Cache + if: ${{ needs.preflight.outputs.dry-run == 'false' }} run: | gh workflow run update-artifactory-cache.yml --repo Devolutions/scheduled-tasks --field package_name="iron-remote-desktop" gh workflow run update-artifactory-cache.yml --repo Devolutions/scheduled-tasks --field package_name="iron-remote-desktop-rdp" From 17833fe009279823c4076d3e2e0c7d063fd24a43 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Thu, 4 Sep 2025 16:35:34 +0000 Subject: [PATCH 016/325] feat: add support for DVC pipe proxy in FFI (#938) --- Cargo.lock | 1 + crates/ironrdp-connector/src/connection.rs | 18 +++ crates/ironrdp-dvc/src/client.rs | 7 + ffi/Cargo.toml | 1 + .../Generated/ActiveStage.cs | 28 ++++ .../Generated/ClientConnector.cs | 23 +++ .../Devolutions.IronRdp/Generated/Config.cs | 28 ++++ .../Generated/ConfigBuilder.cs | 26 ++++ .../Generated/DvcPipeProxyConfig.cs | 123 +++++++++++++++ .../Generated/DvcPipeProxyDescriptor.cs | 85 ++++++++++ .../Generated/DvcPipeProxyMessage.cs | 84 ++++++++++ .../Generated/DvcPipeProxyMessageQueue.cs | 147 ++++++++++++++++++ .../Generated/DvcPipeProxyMessageSink.cs | 63 ++++++++ .../Generated/RawActiveStage.cs | 3 + .../Generated/RawClientConnector.cs | 3 + .../Generated/RawConfig.cs | 3 + .../Generated/RawConfigBuilder.cs | 3 + ...ltBoxDvcPipeProxyMessageBoxIronRdpError.cs | 46 ++++++ ...ptBoxDvcPipeProxyMessageBoxIronRdpError.cs | 46 ++++++ .../Generated/RawDvcPipeProxyConfig.cs | 30 ++++ .../Generated/RawDvcPipeProxyDescriptor.cs | 24 +++ .../Generated/RawDvcPipeProxyMessage.cs | 24 +++ .../Generated/RawDvcPipeProxyMessageQueue.cs | 33 ++++ .../Generated/RawDvcPipeProxyMessageSink.cs | 21 +++ .../Devolutions.IronRdp/src/Connection.cs | 5 + ffi/src/connector/config.rs | 27 +++- ffi/src/connector/mod.rs | 59 +++++-- ffi/src/dvc.rs | 6 - ffi/src/dvc/dvc_pipe_proxy_message_queue.rs | 70 +++++++++ ffi/src/dvc/mod.rs | 49 ++++++ ffi/src/session/mod.rs | 15 ++ 31 files changed, 1080 insertions(+), 21 deletions(-) create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyConfig.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyDescriptor.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessage.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageQueue.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageSink.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyConfig.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyDescriptor.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessage.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageQueue.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageSink.cs delete mode 100644 ffi/src/dvc.rs create mode 100644 ffi/src/dvc/dvc_pipe_proxy_message_queue.rs create mode 100644 ffi/src/dvc/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 835718fc2d..78940cfaa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1511,6 +1511,7 @@ dependencies = [ "ironrdp", "ironrdp-cliprdr-native", "ironrdp-core", + "ironrdp-dvc-pipe-proxy", "sspi", "thiserror 2.0.16", "tracing", diff --git a/crates/ironrdp-connector/src/connection.rs b/crates/ironrdp-connector/src/connection.rs index 6852ce7921..3570c8f80f 100644 --- a/crates/ironrdp-connector/src/connection.rs +++ b/crates/ironrdp-connector/src/connection.rs @@ -154,6 +154,24 @@ impl ClientConnector { self.static_channels.insert(channel); } + pub fn get_static_channel_processor(&mut self) -> Option<&T> + where + T: SvcClientProcessor + 'static, + { + self.static_channels + .get_by_type::() + .and_then(|channel| channel.channel_processor_downcast_ref()) + } + + pub fn get_static_channel_processor_mut(&mut self) -> Option<&mut T> + where + T: SvcClientProcessor + 'static, + { + self.static_channels + .get_by_type_mut::() + .and_then(|channel| channel.channel_processor_downcast_mut()) + } + pub fn should_perform_security_upgrade(&self) -> bool { matches!(self.state, ClientConnectorState::EnhancedSecurityUpgrade { .. }) } diff --git a/crates/ironrdp-dvc/src/client.rs b/crates/ironrdp-dvc/src/client.rs index 30e663e94e..25cf7eeaf8 100644 --- a/crates/ironrdp-dvc/src/client.rs +++ b/crates/ironrdp-dvc/src/client.rs @@ -62,6 +62,13 @@ impl DrdynvcClient { self } + pub fn attach_dynamic_channel(&mut self, channel: T) + where + T: DvcProcessor + 'static, + { + self.dynamic_channels.insert(channel); + } + pub fn get_dvc_by_type_id(&self) -> Option<&DynamicVirtualChannel> where T: DvcProcessor, diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 6d08c88aee..30d98d3db2 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -16,6 +16,7 @@ diplomat = "0.7" diplomat-runtime = "0.7" ironrdp = { path = "../crates/ironrdp", features = ["session", "connector", "dvc", "svc", "rdpdr", "rdpsnd", "graphics", "input", "cliprdr", "displaycontrol"] } ironrdp-cliprdr-native.path = "../crates/ironrdp-cliprdr-native" +ironrdp-dvc-pipe-proxy.path = "../crates/ironrdp-dvc-pipe-proxy" ironrdp-core = { path = "../crates/ironrdp-core", features = ["alloc"] } sspi = { version = "0.16", features = ["network_client"] } thiserror = "2" diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs index 456876009c..af8872e3be 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs @@ -217,6 +217,34 @@ public VecU8 SubmitClipboardFormatData(FormatDataResponse formatDataResponse) } } + /// + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8 SendDvcPipeProxyMessage(DvcPipeProxyMessage message) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ActiveStage"); + } + Raw.DvcPipeProxyMessage* messageRaw; + messageRaw = message.AsFFI(); + if (messageRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessage"); + } + Raw.SessionFfiResultBoxVecU8BoxIronRdpError result = Raw.ActiveStage.SendDvcPipeProxyMessage(_inner, messageRaw); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.VecU8* retVal = result.Ok; + return new VecU8(retVal); + } + } + /// /// /// A ActiveStageOutputIterator allocated on Rust side. diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs index 6bc80d2876..dc9e45c214 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs @@ -128,6 +128,29 @@ public void WithDynamicChannelDisplayControl() } } + /// + public void WithDynamicChannelPipeProxy(DvcPipeProxyConfig config) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ClientConnector"); + } + Raw.DvcPipeProxyConfig* configRaw; + configRaw = config.AsFFI(); + if (configRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyConfig"); + } + Raw.ConnectorFfiResultVoidBoxIronRdpError result = Raw.ClientConnector.WithDynamicChannelPipeProxy(_inner, configRaw); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + } + } + /// public bool ShouldPerformSecurityUpgrade() { diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/Config.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/Config.cs index e234f48232..7a37da1f39 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/Config.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/Config.cs @@ -15,6 +15,14 @@ public partial class Config: IDisposable { private unsafe Raw.Config* _inner; + public DvcPipeProxyConfig? DvcPipeProxy + { + get + { + return GetDvcPipeProxy(); + } + } + /// /// Creates a managed Config from a raw handle. /// @@ -41,6 +49,26 @@ public static ConfigBuilder GetBuilder() } } + /// + /// A DvcPipeProxyConfig allocated on Rust side. + /// + public DvcPipeProxyConfig? GetDvcPipeProxy() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("Config"); + } + Raw.DvcPipeProxyConfig* retVal = Raw.Config.GetDvcPipeProxy(_inner); + if (retVal == null) + { + return null; + } + return new DvcPipeProxyConfig(retVal); + } + } + /// /// Returns the underlying raw handle. /// diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConfigBuilder.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConfigBuilder.cs index 13d86c18d8..9531f84f0d 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConfigBuilder.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConfigBuilder.cs @@ -71,6 +71,14 @@ public string Domain } } + public DvcPipeProxyConfig DvcPipeProxy + { + set + { + SetDvcPipeProxy(value); + } + } + public bool EnableCredssp { set @@ -454,6 +462,24 @@ public void SetPointerSoftwareRendering(bool pointerSoftwareRendering) } } + public void SetDvcPipeProxy(DvcPipeProxyConfig dvcPipeProxy) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ConfigBuilder"); + } + Raw.DvcPipeProxyConfig* dvcPipeProxyRaw; + dvcPipeProxyRaw = dvcPipeProxy.AsFFI(); + if (dvcPipeProxyRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyConfig"); + } + Raw.ConfigBuilder.SetDvcPipeProxy(_inner, dvcPipeProxyRaw); + } + } + /// /// /// A Config allocated on Rust side. diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyConfig.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyConfig.cs new file mode 100644 index 0000000000..b90fca7004 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyConfig.cs @@ -0,0 +1,123 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyConfig: IDisposable +{ + private unsafe Raw.DvcPipeProxyConfig* _inner; + + public DvcPipeProxyMessageSink MessageSink + { + get + { + return GetMessageSink(); + } + } + + /// + /// Creates a managed DvcPipeProxyConfig from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyConfig(Raw.DvcPipeProxyConfig* handle) + { + _inner = handle; + } + + /// + /// A DvcPipeProxyConfig allocated on Rust side. + /// + public static DvcPipeProxyConfig New(DvcPipeProxyMessageSink messageSink) + { + unsafe + { + Raw.DvcPipeProxyMessageSink* messageSinkRaw; + messageSinkRaw = messageSink.AsFFI(); + if (messageSinkRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessageSink"); + } + Raw.DvcPipeProxyConfig* retVal = Raw.DvcPipeProxyConfig.New(messageSinkRaw); + return new DvcPipeProxyConfig(retVal); + } + } + + public void AddPipeProxy(DvcPipeProxyDescriptor descriptor) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyConfig"); + } + Raw.DvcPipeProxyDescriptor* descriptorRaw; + descriptorRaw = descriptor.AsFFI(); + if (descriptorRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyDescriptor"); + } + Raw.DvcPipeProxyConfig.AddPipeProxy(_inner, descriptorRaw); + } + } + + /// + /// A DvcPipeProxyMessageSink allocated on Rust side. + /// + public DvcPipeProxyMessageSink GetMessageSink() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyConfig"); + } + Raw.DvcPipeProxyMessageSink* retVal = Raw.DvcPipeProxyConfig.GetMessageSink(_inner); + return new DvcPipeProxyMessageSink(retVal); + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyConfig* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyConfig.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyConfig() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyDescriptor.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyDescriptor.cs new file mode 100644 index 0000000000..668507a472 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyDescriptor.cs @@ -0,0 +1,85 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyDescriptor: IDisposable +{ + private unsafe Raw.DvcPipeProxyDescriptor* _inner; + + /// + /// Creates a managed DvcPipeProxyDescriptor from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyDescriptor(Raw.DvcPipeProxyDescriptor* handle) + { + _inner = handle; + } + + /// + /// A DvcPipeProxyDescriptor allocated on Rust side. + /// + public static DvcPipeProxyDescriptor New(string channelName, string pipeName) + { + unsafe + { + byte[] channelNameBuf = DiplomatUtils.StringToUtf8(channelName); + byte[] pipeNameBuf = DiplomatUtils.StringToUtf8(pipeName); + nuint channelNameBufLength = (nuint)channelNameBuf.Length; + nuint pipeNameBufLength = (nuint)pipeNameBuf.Length; + fixed (byte* channelNameBufPtr = channelNameBuf) + { + fixed (byte* pipeNameBufPtr = pipeNameBuf) + { + Raw.DvcPipeProxyDescriptor* retVal = Raw.DvcPipeProxyDescriptor.New(channelNameBufPtr, channelNameBufLength, pipeNameBufPtr, pipeNameBufLength); + return new DvcPipeProxyDescriptor(retVal); + } + } + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyDescriptor* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyDescriptor.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyDescriptor() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessage.cs new file mode 100644 index 0000000000..3343d9f712 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessage.cs @@ -0,0 +1,84 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyMessage: IDisposable +{ + private unsafe Raw.DvcPipeProxyMessage* _inner; + + public uint ChannelId + { + get + { + return GetChannelId(); + } + } + + /// + /// Creates a managed DvcPipeProxyMessage from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyMessage(Raw.DvcPipeProxyMessage* handle) + { + _inner = handle; + } + + public uint GetChannelId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessage"); + } + uint retVal = Raw.DvcPipeProxyMessage.GetChannelId(_inner); + return retVal; + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyMessage* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyMessage.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyMessage() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageQueue.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageQueue.cs new file mode 100644 index 0000000000..b53ac53cfe --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageQueue.cs @@ -0,0 +1,147 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyMessageQueue: IDisposable +{ + private unsafe Raw.DvcPipeProxyMessageQueue* _inner; + + public DvcPipeProxyMessageSink Sink + { + get + { + return GetSink(); + } + } + + /// + /// Creates a managed DvcPipeProxyMessageQueue from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyMessageQueue(Raw.DvcPipeProxyMessageQueue* handle) + { + _inner = handle; + } + + /// + /// A DvcPipeProxyMessageQueue allocated on Rust side. + /// + public static DvcPipeProxyMessageQueue New(uint queueSize) + { + unsafe + { + Raw.DvcPipeProxyMessageQueue* retVal = Raw.DvcPipeProxyMessageQueue.New(queueSize); + return new DvcPipeProxyMessageQueue(retVal); + } + } + + /// + /// + /// A DvcPipeProxyMessage allocated on Rust side. + /// + public DvcPipeProxyMessage NextMessage() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessageQueue"); + } + Raw.DvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError result = Raw.DvcPipeProxyMessageQueue.NextMessage(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.DvcPipeProxyMessage* retVal = result.Ok; + if (retVal == null) + { + return null; + } + return new DvcPipeProxyMessage(retVal); + } + } + + /// + /// + /// A DvcPipeProxyMessage allocated on Rust side. + /// + public DvcPipeProxyMessage NextMessageBlocking() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessageQueue"); + } + Raw.DvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError result = Raw.DvcPipeProxyMessageQueue.NextMessageBlocking(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.DvcPipeProxyMessage* retVal = result.Ok; + return new DvcPipeProxyMessage(retVal); + } + } + + /// + /// A DvcPipeProxyMessageSink allocated on Rust side. + /// + public DvcPipeProxyMessageSink GetSink() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessageQueue"); + } + Raw.DvcPipeProxyMessageSink* retVal = Raw.DvcPipeProxyMessageQueue.GetSink(_inner); + return new DvcPipeProxyMessageSink(retVal); + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyMessageQueue* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyMessageQueue.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyMessageQueue() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageSink.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageSink.cs new file mode 100644 index 0000000000..c2d9b98b11 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageSink.cs @@ -0,0 +1,63 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyMessageSink: IDisposable +{ + private unsafe Raw.DvcPipeProxyMessageSink* _inner; + + /// + /// Creates a managed DvcPipeProxyMessageSink from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyMessageSink(Raw.DvcPipeProxyMessageSink* handle) + { + _inner = handle; + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyMessageSink* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyMessageSink.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyMessageSink() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs index 575a4f5594..40d5335605 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs @@ -34,6 +34,9 @@ public partial struct ActiveStage [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_submit_clipboard_format_data", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxVecU8BoxIronRdpError SubmitClipboardFormatData(ActiveStage* self, FormatDataResponse* formatDataResponse); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_send_dvc_pipe_proxy_message", ExactSpelling = true)] + public static unsafe extern SessionFfiResultBoxVecU8BoxIronRdpError SendDvcPipeProxyMessage(ActiveStage* self, DvcPipeProxyMessage* message); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_graceful_shutdown", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxActiveStageOutputIteratorBoxIronRdpError GracefulShutdown(ActiveStage* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs index d030866791..9a4fcc42cd 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs @@ -34,6 +34,9 @@ public partial struct ClientConnector [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClientConnector_with_dynamic_channel_display_control", ExactSpelling = true)] public static unsafe extern ConnectorFfiResultVoidBoxIronRdpError WithDynamicChannelDisplayControl(ClientConnector* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClientConnector_with_dynamic_channel_pipe_proxy", ExactSpelling = true)] + public static unsafe extern ConnectorFfiResultVoidBoxIronRdpError WithDynamicChannelPipeProxy(ClientConnector* self, DvcPipeProxyConfig* config); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClientConnector_should_perform_security_upgrade", ExactSpelling = true)] public static unsafe extern ConnectorFfiResultBoolBoxIronRdpError ShouldPerformSecurityUpgrade(ClientConnector* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfig.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfig.cs index 1eb7592e2a..8d2cc8ebc5 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfig.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfig.cs @@ -19,6 +19,9 @@ public partial struct Config [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "Config_get_builder", ExactSpelling = true)] public static unsafe extern ConfigBuilder* GetBuilder(); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "Config_get_dvc_pipe_proxy", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyConfig* GetDvcPipeProxy(Config* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "Config_destroy", ExactSpelling = true)] public static unsafe extern void Destroy(Config* self); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfigBuilder.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfigBuilder.cs index 6314b47af0..bf7652311d 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfigBuilder.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfigBuilder.cs @@ -76,6 +76,9 @@ public partial struct ConfigBuilder [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConfigBuilder_set_pointer_software_rendering", ExactSpelling = true)] public static unsafe extern void SetPointerSoftwareRendering(ConfigBuilder* self, [MarshalAs(UnmanagedType.U1)] bool pointerSoftwareRendering); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConfigBuilder_set_dvc_pipe_proxy", ExactSpelling = true)] + public static unsafe extern void SetDvcPipeProxy(ConfigBuilder* self, DvcPipeProxyConfig* dvcPipeProxy); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConfigBuilder_build", ExactSpelling = true)] public static unsafe extern ConnectorConfigFfiResultBoxConfigBoxIronRdpError Build(ConfigBuilder* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError.cs new file mode 100644 index 0000000000..cb8d17407f --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal DvcPipeProxyMessage* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe DvcPipeProxyMessage* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError.cs new file mode 100644 index 0000000000..070066774a --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal DvcPipeProxyMessage* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe DvcPipeProxyMessage* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyConfig.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyConfig.cs new file mode 100644 index 0000000000..81d4ee18a0 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyConfig.cs @@ -0,0 +1,30 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyConfig +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyConfig_new", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyConfig* New(DvcPipeProxyMessageSink* messageSink); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyConfig_add_pipe_proxy", ExactSpelling = true)] + public static unsafe extern void AddPipeProxy(DvcPipeProxyConfig* self, DvcPipeProxyDescriptor* descriptor); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyConfig_get_message_sink", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyMessageSink* GetMessageSink(DvcPipeProxyConfig* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyConfig_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyConfig* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyDescriptor.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyDescriptor.cs new file mode 100644 index 0000000000..e0f8d76f4f --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyDescriptor.cs @@ -0,0 +1,24 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyDescriptor +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyDescriptor_new", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyDescriptor* New(byte* channelName, nuint channelNameSz, byte* pipeName, nuint pipeNameSz); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyDescriptor_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyDescriptor* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessage.cs new file mode 100644 index 0000000000..9d155a70cd --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessage.cs @@ -0,0 +1,24 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyMessage +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessage_get_channel_id", ExactSpelling = true)] + public static unsafe extern uint GetChannelId(DvcPipeProxyMessage* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessage_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyMessage* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageQueue.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageQueue.cs new file mode 100644 index 0000000000..f31d991017 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageQueue.cs @@ -0,0 +1,33 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyMessageQueue +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_new", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyMessageQueue* New(uint queueSize); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_next_message", ExactSpelling = true)] + public static unsafe extern DvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError NextMessage(DvcPipeProxyMessageQueue* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_next_message_blocking", ExactSpelling = true)] + public static unsafe extern DvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError NextMessageBlocking(DvcPipeProxyMessageQueue* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_get_sink", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyMessageSink* GetSink(DvcPipeProxyMessageQueue* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyMessageQueue* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageSink.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageSink.cs new file mode 100644 index 0000000000..2819ce1570 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageSink.cs @@ -0,0 +1,21 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyMessageSink +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageSink_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyMessageSink* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs b/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs index ed638b9342..0a03fd6e09 100644 --- a/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs +++ b/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs @@ -18,6 +18,11 @@ public static class Connection var connector = ClientConnector.New(config, clientAddr); connector.WithDynamicChannelDisplayControl(); + var dvcPipeProxy = config.DvcPipeProxy; + if (dvcPipeProxy != null) + { + connector.WithDynamicChannelPipeProxy(dvcPipeProxy); + } if (factory != null) { diff --git a/ffi/src/connector/config.rs b/ffi/src/connector/config.rs index 405075c8bf..68d148dc87 100644 --- a/ffi/src/connector/config.rs +++ b/ffi/src/connector/config.rs @@ -7,15 +7,23 @@ pub mod ffi { use ironrdp::connector::Credentials; use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; + use crate::dvc::ffi::DvcPipeProxyConfig; use crate::error::ffi::IronRdpError; #[diplomat::opaque] - pub struct Config(pub ironrdp::connector::Config); + pub struct Config { + pub connector: ironrdp::connector::Config, + pub dvc_pipe_proxy: Option, + } impl Config { pub fn get_builder() -> Box { Box::::default() } + + pub fn get_dvc_pipe_proxy(&self) -> Option> { + self.dvc_pipe_proxy.as_ref().map(|dvc| Box::new(dvc.clone())) + } } #[derive(Default)] @@ -43,6 +51,7 @@ pub mod ffi { pub pointer_software_rendering: Option, pub performance_flags: Option, pub timezone_info: Option, + pub dvc_pipe_proxy: Option, } #[diplomat::enum_convert(ironrdp::pdu::gcc::KeyboardType)] @@ -157,8 +166,12 @@ pub mod ffi { self.pointer_software_rendering = Some(pointer_software_rendering); } + pub fn set_dvc_pipe_proxy(&mut self, dvc_pipe_proxy: &DvcPipeProxyConfig) { + self.dvc_pipe_proxy = Some(dvc_pipe_proxy.clone()); + } + pub fn build(&self) -> Result, Box> { - let inner_config = ironrdp::connector::Config { + let connector = ironrdp::connector::Config { credentials: self.credentials.clone().ok_or("credentials not set")?, domain: self.domain.clone(), enable_tls: self.enable_tls.unwrap_or(false), @@ -207,8 +220,14 @@ pub mod ffi { license_cache: None, timezone_info: self.timezone_info.clone().unwrap_or_default(), }; - tracing::debug!(config=?inner_config, "Built config"); - Ok(Box::new(Config(inner_config))) + let dvc_pipe_proxy = self.dvc_pipe_proxy.clone(); + + tracing::debug!(config=?connector, "Built config"); + + Ok(Box::new(Config { + connector, + dvc_pipe_proxy, + })) } } diff --git a/ffi/src/connector/mod.rs b/ffi/src/connector/mod.rs index c41defec9a..4d62729c7e 100644 --- a/ffi/src/connector/mod.rs +++ b/ffi/src/connector/mod.rs @@ -10,12 +10,16 @@ pub mod ffi { use diplomat_runtime::DiplomatWriteable; use ironrdp::connector::Sequence as _; use ironrdp::displaycontrol::client::DisplayControlClient; + use ironrdp::dvc::DvcProcessor; + use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; use tracing::info; use super::config::ffi::Config; use super::result::ffi::Written; use super::state::ffi::ClientConnectorState; use crate::clipboard::ffi::Cliprdr; + use crate::dvc::dvc_pipe_proxy_message_queue::DvcPipeProxyMessageInner; + use crate::dvc::ffi::DvcPipeProxyConfig; use crate::error::ffi::{IronRdpError, IronRdpErrorKind}; use crate::error::ValueConsumedError; use crate::pdu::ffi::WriteBuf; @@ -29,7 +33,7 @@ pub mod ffi { let client_addr = client_addr.parse().map_err(|_| IronRdpErrorKind::Generic)?; Ok(Box::new(ClientConnector(Some( - ironrdp::connector::ClientConnector::new(config.0.clone(), client_addr), + ironrdp::connector::ClientConnector::new(config.connector.clone(), client_addr), )))) } @@ -68,19 +72,52 @@ pub mod ffi { Ok(()) } - pub fn with_dynamic_channel_display_control(&mut self) -> Result<(), Box> { - let Some(connector) = self.0.take() else { + fn with_dvc(&mut self, processor: T) -> Result<(), Box> + where + T: DvcProcessor + 'static, + { + let Some(connector) = &mut self.0 else { return Err(ValueConsumedError::for_item("connector").into()); }; - self.0 = Some( - connector.with_static_channel(ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel( - DisplayControlClient::new(|c| { - info!(DisplayCountrolCapabilities = ?c, "DisplayControl capabilities received"); - Ok(Vec::new()) - }), - )), - ); + let drdynvc = match connector.get_static_channel_processor_mut::() { + Some(processor) => processor, + None => { + connector.attach_static_channel(ironrdp::dvc::DrdynvcClient::new()); + connector + .get_static_channel_processor_mut::() + .expect("DrdynvcClient should be initialized above") + } + }; + + drdynvc.attach_dynamic_channel(processor); + + Ok(()) + } + + pub fn with_dynamic_channel_display_control(&mut self) -> Result<(), Box> { + self.with_dvc(DisplayControlClient::new(|c| { + info!(DisplayCountrolCapabilities = ?c, "DisplayControl capabilities received"); + Ok(Vec::new()) + })) + } + + pub fn with_dynamic_channel_pipe_proxy( + &mut self, + config: &DvcPipeProxyConfig, + ) -> Result<(), Box> { + for descriptor in &config.descriptors { + let sink = config.message_sink.0.clone(); + let proxy = DvcNamedPipeProxy::new( + &descriptor.channel_name, + &descriptor.pipe_name, + move |channel_id, svc_message| { + let _ = sink.send(DvcPipeProxyMessageInner(channel_id, svc_message)); + Ok(()) + }, + ); + self.with_dvc(proxy)?; + } Ok(()) } diff --git a/ffi/src/dvc.rs b/ffi/src/dvc.rs deleted file mode 100644 index 1618237a17..0000000000 --- a/ffi/src/dvc.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[diplomat::bridge] -pub mod ffi { - - #[diplomat::opaque] - pub struct DrdynvcChannel(pub ironrdp::dvc::DrdynvcClient); -} diff --git a/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs b/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs new file mode 100644 index 0000000000..ce67b0c154 --- /dev/null +++ b/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs @@ -0,0 +1,70 @@ +use ironrdp::svc::SvcMessage; +use std::sync::mpsc; + +#[diplomat::bridge] +pub mod ffi { + use crate::error::ffi::IronRdpError; + use std::sync::mpsc; + + use super::{DvcPipeProxyMessageInner, DvcPipeProxyMessageQueueInner}; + + #[diplomat::opaque] + pub struct DvcPipeProxyMessage(pub DvcPipeProxyMessageInner); + + impl DvcPipeProxyMessage { + pub fn get_channel_id(&self) -> u32 { + self.0 .0 + } + } + + #[diplomat::opaque] + #[derive(Clone)] + pub struct DvcPipeProxyMessageSink(pub mpsc::SyncSender); + + #[diplomat::opaque] + pub struct DvcPipeProxyMessageQueue(DvcPipeProxyMessageQueueInner); + + impl DvcPipeProxyMessageQueue { + pub fn new(queue_size: u32) -> Box { + let queue_size = usize::try_from(queue_size).expect("invalid dvc pipe proxy message queue size"); + + Box::new(DvcPipeProxyMessageQueue(DvcPipeProxyMessageQueueInner::new(queue_size))) + } + + pub fn next_message(&self) -> Result>, Box> { + Ok(self.0.next_message().map(DvcPipeProxyMessage).map(Box::new)) + } + + pub fn next_message_blocking(&self) -> Result, Box> { + let message = self.0.next_message_blocking().map(DvcPipeProxyMessage).map(Box::new)?; + + Ok(message) + } + + pub fn get_sink(&self) -> Box { + Box::new(DvcPipeProxyMessageSink(self.0.tx.clone())) + } + } +} + +struct DvcPipeProxyMessageQueueInner { + tx: mpsc::SyncSender, + rx: mpsc::Receiver, +} + +impl DvcPipeProxyMessageQueueInner { + fn new(queue_size: usize) -> Self { + let (tx, rx) = mpsc::sync_channel(queue_size); + Self { tx, rx } + } + + fn next_message(&self) -> Option { + self.rx.try_recv().ok() + } + + fn next_message_blocking(&self) -> Result { + self.rx.recv().map_err(|_| "failed to receive dvc pipe proxy message") + } +} + +pub struct DvcPipeProxyMessageInner(pub u32, pub Vec); diff --git a/ffi/src/dvc/mod.rs b/ffi/src/dvc/mod.rs new file mode 100644 index 0000000000..16e7d2782c --- /dev/null +++ b/ffi/src/dvc/mod.rs @@ -0,0 +1,49 @@ +pub mod dvc_pipe_proxy_message_queue; + +#[diplomat::bridge] +pub mod ffi { + use crate::dvc::dvc_pipe_proxy_message_queue::ffi::DvcPipeProxyMessageSink; + + #[diplomat::opaque] + pub struct DrdynvcChannel(pub ironrdp::dvc::DrdynvcClient); + + #[diplomat::opaque] + #[derive(Clone)] + pub struct DvcPipeProxyDescriptor { + pub channel_name: String, + pub pipe_name: String, + } + + impl DvcPipeProxyDescriptor { + pub fn new(channel_name: &str, pipe_name: &str) -> Box { + Box::new(DvcPipeProxyDescriptor { + channel_name: channel_name.to_owned(), + pipe_name: pipe_name.to_owned(), + }) + } + } + + #[diplomat::opaque] + #[derive(Clone)] + pub struct DvcPipeProxyConfig { + pub message_sink: DvcPipeProxyMessageSink, + pub descriptors: Vec, + } + + impl DvcPipeProxyConfig { + pub fn new(message_sink: &DvcPipeProxyMessageSink) -> Box { + Box::new(DvcPipeProxyConfig { + message_sink: message_sink.clone(), + descriptors: Vec::new(), + }) + } + + pub fn add_pipe_proxy(&mut self, descriptor: &DvcPipeProxyDescriptor) { + self.descriptors.push(descriptor.clone()); + } + + pub fn get_message_sink(&self) -> Box { + Box::new(self.message_sink.clone()) + } + } +} diff --git a/ffi/src/session/mod.rs b/ffi/src/session/mod.rs index 6a6b1fa5bd..5f030c8116 100644 --- a/ffi/src/session/mod.rs +++ b/ffi/src/session/mod.rs @@ -7,6 +7,7 @@ pub mod ffi { use crate::clipboard::message::ffi::{ClipboardFormatId, ClipboardFormatIterator, FormatDataResponse}; use crate::connector::activation::ffi::ConnectionActivationSequence; use crate::connector::result::ffi::ConnectionResult; + use crate::dvc::dvc_pipe_proxy_message_queue::ffi::DvcPipeProxyMessage; use crate::error::ffi::IronRdpError; use crate::error::{IncorrectEnumTypeError, ValueConsumedError}; use crate::graphics::ffi::DecodedPointer; @@ -121,6 +122,20 @@ pub mod ffi { Ok(Box::new(VecU8(frame))) } + pub fn send_dvc_pipe_proxy_message( + &mut self, + message: &mut DvcPipeProxyMessage, + ) -> Result, Box> { + let messages = core::mem::take(&mut message.0 .1); + + if messages.is_empty() { + return Err("no dvc messages to send (message sent twice?)".into()); + } + + let frame = self.0.encode_dvc_messages(messages)?; + Ok(Box::new(VecU8(frame))) + } + pub fn graceful_shutdown(&mut self) -> Result, Box> { let outputs = self.0.graceful_shutdown()?; Ok(Box::new(ActiveStageOutputIterator(outputs))) From 4beab02353c37e2e061d4025d828a5b3e7fbd820 Mon Sep 17 00:00:00 2001 From: devolutionsbot <31221910+devolutionsbot@users.noreply.github.com> Date: Thu, 4 Sep 2025 13:06:32 -0400 Subject: [PATCH 017/325] chore(release): prepare for publishing (#960) --- Cargo.lock | 224 +++++++++++---------- crates/ironrdp-cliprdr-format/CHANGELOG.md | 8 +- crates/ironrdp-cliprdr-format/Cargo.toml | 2 +- crates/ironrdp-connector/CHANGELOG.md | 6 + crates/ironrdp-connector/Cargo.toml | 2 +- crates/ironrdp-dvc/CHANGELOG.md | 10 +- crates/ironrdp-dvc/Cargo.toml | 2 +- crates/ironrdp-rdpdr/CHANGELOG.md | 6 + crates/ironrdp-rdpdr/Cargo.toml | 2 +- fuzz/Cargo.lock | 25 ++- 10 files changed, 163 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78940cfaa8..9be922cd7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,7 +106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.9.3", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -128,7 +128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" dependencies = [ "android-properties", - "bitflags 2.9.3", + "bitflags 2.9.4", "cc", "cesu8", "jni", @@ -436,7 +436,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "cexpr", "clang-sys", "itertools 0.12.1", @@ -482,9 +482,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.3" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "bitvec" @@ -590,7 +590,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "log", "polling", "rustix 0.38.44", @@ -627,10 +627,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.34" +version = "1.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -727,9 +728,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -737,9 +738,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -749,9 +750,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.45" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", @@ -860,7 +861,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -884,7 +885,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "core-foundation 0.10.1", "libc", ] @@ -1265,7 +1266,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "objc2 0.6.2", ] @@ -1313,7 +1314,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98888c4bbd601524c11a7ed63f814b8825f420514f78e96f752c437ae9cbb5d1" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "bytemuck", "drm-ffi", "drm-fourcc", @@ -1524,6 +1525,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" + [[package]] name = "flagset" version = "0.4.7" @@ -1735,12 +1742,12 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.4.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.0.8", + "windows-targets 0.52.6", ] [[package]] @@ -2225,14 +2232,15 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", + "moxcms", "num-traits", - "png 0.17.16", + "png", ] [[package]] @@ -2261,7 +2269,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "crossterm", "dyn-clone", "fuzzy-matcher", @@ -2278,7 +2286,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -2372,7 +2380,7 @@ dependencies = [ name = "ironrdp-ainput" version = "0.4.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "ironrdp-core", "ironrdp-dvc", "num-derive", @@ -2462,7 +2470,7 @@ dependencies = [ name = "ironrdp-cliprdr" version = "0.4.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -2471,10 +2479,10 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-format" -version = "0.1.3" +version = "0.1.4" dependencies = [ "ironrdp-core", - "png 0.18.0", + "png", ] [[package]] @@ -2489,7 +2497,7 @@ dependencies = [ [[package]] name = "ironrdp-connector" -version = "0.7.0" +version = "0.7.1" dependencies = [ "arbitrary", "ironrdp-core", @@ -2525,7 +2533,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.4.0" +version = "0.4.1" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2581,7 +2589,7 @@ name = "ironrdp-graphics" version = "0.5.0" dependencies = [ "bit_field", - "bitflags 2.9.3", + "bitflags 2.9.4", "bitvec", "bmp", "bytemuck", @@ -2609,7 +2617,7 @@ name = "ironrdp-mstsgu" version = "0.0.1" dependencies = [ "base64", - "bitflags 2.9.3", + "bitflags 2.9.4", "futures-util", "http-body-util", "hyper", @@ -2629,7 +2637,7 @@ name = "ironrdp-pdu" version = "0.6.0" dependencies = [ "bit_field", - "bitflags 2.9.3", + "bitflags 2.9.4", "byteorder", "der-parser", "expect-test", @@ -2668,9 +2676,9 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr" -version = "0.4.0" +version = "0.4.1" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "ironrdp-core", "ironrdp-error", "ironrdp-pdu", @@ -2701,7 +2709,7 @@ dependencies = [ name = "ironrdp-rdpsnd" version = "0.6.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -2776,7 +2784,7 @@ version = "0.0.0" name = "ironrdp-svc" version = "0.5.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "ironrdp-core", "ironrdp-pdu", ] @@ -2806,7 +2814,7 @@ dependencies = [ "ironrdp-session", "lazy_static", "paste", - "png 0.18.0", + "png", "pretty_assertions", "proptest", "rstest", @@ -2874,7 +2882,7 @@ dependencies = [ "ironrdp-rdcleanpath", "ironrdp-rdpfile", "js-sys", - "png 0.18.0", + "png", "resize", "rgb", "semver", @@ -3014,7 +3022,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "libc", "redox_syscall 0.5.17", ] @@ -3061,9 +3069,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "lru-cache" @@ -3171,6 +3179,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moxcms" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -3194,7 +3212,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "jni-sys", "log", "ndk-sys", @@ -3233,7 +3251,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "cfg-if", "cfg_aliases", "libc", @@ -3386,7 +3404,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "libc", "objc2 0.5.2", @@ -3402,7 +3420,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "libc", "objc2 0.6.2", "objc2-core-audio", @@ -3417,7 +3435,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "objc2 0.5.2", "objc2-core-location", @@ -3453,7 +3471,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "objc2 0.6.2", ] @@ -3463,7 +3481,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3475,7 +3493,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "dispatch2", "objc2 0.6.2", ] @@ -3516,7 +3534,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "dispatch", "libc", @@ -3550,7 +3568,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3562,7 +3580,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3585,7 +3603,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "objc2 0.5.2", "objc2-cloud-kit", @@ -3617,7 +3635,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "objc2 0.5.2", "objc2-core-location", @@ -3672,7 +3690,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4029,26 +4047,13 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "png" version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "crc32fast", "fdeflate", "flate2", @@ -4175,7 +4180,7 @@ checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.3", + "bitflags 2.9.4", "lazy_static", "num-traits", "rand 0.9.2", @@ -4187,6 +4192,15 @@ dependencies = [ "unarray", ] +[[package]] +name = "pxfm" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e790881194f6f6e86945f0a42a6981977323669aeb6c40e9c7ec253133b96f8" +dependencies = [ + "num-traits", +] + [[package]] name = "qoicoubeh" version = "0.5.0" @@ -4405,7 +4419,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", ] [[package]] @@ -4628,7 +4642,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4641,7 +4655,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.9.4", @@ -4794,7 +4808,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -4807,7 +4821,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5003,7 +5017,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "calloop", "calloop-wayland-source", "cursor-icon", @@ -5107,7 +5121,7 @@ checksum = "523f6a99e26c1e6476a424d54bbda5354a01ee7f18b9d93dc48a8fd45ae8189b" dependencies = [ "async-dnssd", "async-recursion", - "bitflags 2.9.3", + "bitflags 2.9.4", "byteorder", "cfg-if", "crypto-mac", @@ -5224,7 +5238,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5309,9 +5323,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.42" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca967379f9d8eb8058d86ed467d81d03e81acd45757e4ca341c24affbe8e8e3" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", "js-sys", @@ -5324,15 +5338,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9108bb380861b07264b950ded55a44a14a4adc68b9f5efd85aafc3aa4d40a68" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7182799245a7264ce590b349d90338f1c1affad93d2639aed5f8f69c090b334c" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -5597,7 +5611,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "bytes", "futures-util", "http", @@ -5822,9 +5836,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -6021,7 +6035,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "rustix 1.0.8", "wayland-backend", "wayland-scanner", @@ -6033,7 +6047,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "cursor-icon", "wayland-backend", ] @@ -6055,7 +6069,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-scanner", @@ -6067,7 +6081,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6080,7 +6094,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6647,7 +6661,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.9.3", + "bitflags 2.9.4", "block2", "bytemuck", "calloop", @@ -6753,24 +6767,24 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", "libloading", "once_cell", - "rustix 0.38.44", + "rustix 1.0.8", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "x25519-dalek" @@ -6808,7 +6822,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.9.4", "dlib", "log", "once_cell", @@ -6990,9 +7004,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/crates/ironrdp-cliprdr-format/CHANGELOG.md b/crates/ironrdp-cliprdr-format/CHANGELOG.md index 61cf2d5f64..717d9c1c89 100644 --- a/crates/ironrdp-cliprdr-format/CHANGELOG.md +++ b/crates/ironrdp-cliprdr-format/CHANGELOG.md @@ -6,17 +6,21 @@ 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). +## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.3...ironrdp-cliprdr-format-v0.1.4)] - 2025-09-04 + +### Build + +- Bump png from 0.17.16 to 0.18.0 (#961) ([21fa028dff](https://github.com/Devolutions/IronRDP/commit/21fa028dffa5f9bb1498b4d48d063ea42929faf5)) + ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.2...ironrdp-cliprdr-format-v0.1.3)] - 2025-03-12 ### Build - Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.1...ironrdp-cliprdr-format-v0.1.2)] - 2025-01-28 ### Documentation - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - diff --git a/crates/ironrdp-cliprdr-format/Cargo.toml b/crates/ironrdp-cliprdr-format/Cargo.toml index b979d0cecc..947c3039a1 100644 --- a/crates/ironrdp-cliprdr-format/Cargo.toml +++ b/crates/ironrdp-cliprdr-format/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-cliprdr-format" -version = "0.1.3" +version = "0.1.4" readme = "README.md" description = "CLIPRDR format conversion library" edition.workspace = true diff --git a/crates/ironrdp-connector/CHANGELOG.md b/crates/ironrdp-connector/CHANGELOG.md index 4979ced9cc..6fe86b40f3 100644 --- a/crates/ironrdp-connector/CHANGELOG.md +++ b/crates/ironrdp-connector/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.7.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.7.0...ironrdp-connector-v0.7.1)] - 2025-09-04 + +### Features + +- Add API to retrieve registered SVC processors (#938) ([17833fe009](https://github.com/Devolutions/IronRDP/commit/17833fe009279823c4076d3e2e0c7d063fd24a43)) + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.6.0...ironrdp-connector-v0.7.0)] - 2025-08-29 ### Features diff --git a/crates/ironrdp-connector/Cargo.toml b/crates/ironrdp-connector/Cargo.toml index 31423e3dc4..2ff693fbbd 100644 --- a/crates/ironrdp-connector/Cargo.toml +++ b/crates/ironrdp-connector/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-connector" -version = "0.7.0" +version = "0.7.1" readme = "README.md" description = "State machines to drive an RDP connection sequence" edition.workspace = true diff --git a/crates/ironrdp-dvc/CHANGELOG.md b/crates/ironrdp-dvc/CHANGELOG.md index c6bc06caaa..6eb3c6a594 100644 --- a/crates/ironrdp-dvc/CHANGELOG.md +++ b/crates/ironrdp-dvc/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.4.0...ironrdp-dvc-v0.4.1)] - 2025-09-04 + +### Features + +- Add API to attach dynamic channels to an already created `DrdynvcClient` instance (#938) ([17833fe009](https://github.com/Devolutions/IronRDP/commit/17833fe009279823c4076d3e2e0c7d063fd24a43)) + ## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.3.0...ironrdp-dvc-v0.3.1)] - 2025-06-27 ### Features @@ -18,8 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.1.2...ironrdp-dvc-v0.1.3)] - 2025-03-12 ### Build @@ -40,8 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.1.0...ironrdp-dvc-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-dvc/Cargo.toml b/crates/ironrdp-dvc/Cargo.toml index d286757007..c0d17f9b57 100644 --- a/crates/ironrdp-dvc/Cargo.toml +++ b/crates/ironrdp-dvc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc" -version = "0.4.0" +version = "0.4.1" readme = "README.md" description = "DRDYNVC static channel implementation and traits to implement dynamic virtual channels" edition.workspace = true diff --git a/crates/ironrdp-rdpdr/CHANGELOG.md b/crates/ironrdp-rdpdr/CHANGELOG.md index e5e5f8dad7..99da423df4 100644 --- a/crates/ironrdp-rdpdr/CHANGELOG.md +++ b/crates/ironrdp-rdpdr/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.4.0...ironrdp-rdpdr-v0.4.1)] - 2025-09-04 + +### Features + +- Support device removal (#947) ([50574c570f](https://github.com/Devolutions/IronRDP/commit/50574c570f6e44d264153337e5f87a5313f190e6)) + ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.2.0...ironrdp-rdpdr-v0.3.0)] - 2025-05-27 ### Features diff --git a/crates/ironrdp-rdpdr/Cargo.toml b/crates/ironrdp-rdpdr/Cargo.toml index c94aa7eea0..32a762e798 100644 --- a/crates/ironrdp-rdpdr/Cargo.toml +++ b/crates/ironrdp-rdpdr/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpdr" -version = "0.4.0" +version = "0.4.1" readme = "README.md" description = "RDPDR channel implementation." edition.workspace = true diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index de4d668ad6..533f72e858 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -75,9 +75,9 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.9.3" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "bitvec" @@ -108,10 +108,11 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.34" +version = "1.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -234,6 +235,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" + [[package]] name = "flagset" version = "0.4.7" @@ -291,7 +298,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-format" -version = "0.1.3" +version = "0.1.4" dependencies = [ "ironrdp-core", "png", @@ -317,7 +324,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.4.0" +version = "0.4.1" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -394,7 +401,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr" -version = "0.4.0" +version = "0.4.1" dependencies = [ "bitflags", "ironrdp-core", @@ -458,9 +465,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "md-5" From e8d7570cd19a8d52483b620e95092b751c83a2c9 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Thu, 4 Sep 2025 20:06:48 +0300 Subject: [PATCH 018/325] refactor(pdu)!: fix unwrap_used clippy lint warnings (#964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Benoît CORTIER --- crates/ironrdp-acceptor/src/connection.rs | 20 +-- crates/ironrdp-connector/src/connection.rs | 7 +- .../ironrdp-pdu/src/basic_output/fast_path.rs | 38 ++++-- .../src/basic_output/surface_commands.rs | 16 ++- crates/ironrdp-pdu/src/codecs/rfx.rs | 16 ++- .../src/codecs/rfx/data_messages.rs | 20 ++- crates/ironrdp-pdu/src/gcc.rs | 66 +++++++--- crates/ironrdp-pdu/src/gcc/cluster_data.rs | 19 ++- .../ironrdp-pdu/src/gcc/conference_create.rs | 95 +++++++++++--- .../ironrdp-pdu/src/gcc/core_data/client.rs | 77 +++++++++-- .../src/gcc/monitor_extended_data.rs | 19 ++- crates/ironrdp-pdu/src/gcc/security_data.rs | 18 ++- crates/ironrdp-pdu/src/geometry.rs | 2 + crates/ironrdp-pdu/src/input/fast_path.rs | 18 ++- crates/ironrdp-pdu/src/input/mod.rs | 18 ++- crates/ironrdp-pdu/src/mcs.rs | 14 +- crates/ironrdp-pdu/src/per.rs | 6 +- crates/ironrdp-pdu/src/rdp/capability_sets.rs | 53 +++++--- .../src/rdp/capability_sets/bitmap_codecs.rs | 15 ++- .../src/rdp/capability_sets/brush.rs | 19 ++- .../src/rdp/capability_sets/glyph_cache.rs | 19 ++- .../src/rdp/capability_sets/input.rs | 4 +- crates/ironrdp-pdu/src/rdp/client_info.rs | 120 +++++++++++++----- .../src/rdp/finalization_messages.rs | 18 ++- crates/ironrdp-pdu/src/rdp/headers.rs | 50 ++++++-- .../ironrdp-pdu/src/rdp/server_error_info.rs | 75 +++++++---- crates/ironrdp-pdu/src/rdp/server_license.rs | 35 ++++- .../client_platform_challenge_response.rs | 38 ++++-- .../server_license/licensing_error_message.rs | 34 ++++- .../server_license/server_license_request.rs | 6 +- crates/ironrdp-pdu/src/rdp/session_info.rs | 18 ++- .../src/rdp/session_info/logon_extended.rs | 32 ++++- crates/ironrdp-pdu/src/rdp/suppress_output.rs | 6 +- crates/ironrdp-pdu/src/rdp/vc/dvc/gfx.rs | 34 ++++- .../src/rdp/vc/dvc/gfx/graphics_messages.rs | 18 ++- .../vc/dvc/gfx/graphics_messages/server.rs | 46 +++++-- crates/ironrdp-pdu/src/utils.rs | 26 ++-- crates/ironrdp-pdu/src/x224.rs | 17 +-- .../src/conference_create.rs | 12 +- 39 files changed, 863 insertions(+), 301 deletions(-) diff --git a/crates/ironrdp-acceptor/src/connection.rs b/crates/ironrdp-acceptor/src/connection.rs index 68e214b755..f4355293e9 100644 --- a/crates/ironrdp-acceptor/src/connection.rs +++ b/crates/ironrdp-acceptor/src/connection.rs @@ -381,16 +381,10 @@ impl Sequence for Acceptor { debug!(message = ?settings_initial, "Received"); - let early_capability = settings_initial - .conference_create_request - .gcc_blocks - .core - .optional_data - .early_capability_flags; - - let joined: Vec<_> = settings_initial - .conference_create_request - .gcc_blocks + let gcc_blocks = settings_initial.conference_create_request.into_gcc_blocks(); + let early_capability = gcc_blocks.core.optional_data.early_capability_flags; + + let joined: Vec<_> = gcc_blocks .network .map(|network| { network @@ -450,10 +444,8 @@ impl Sequence for Acceptor { ); let settings_response = mcs::ConnectResponse { - conference_create_response: gcc::ConferenceCreateResponse { - user_id: self.user_channel_id, - gcc_blocks: server_blocks, - }, + conference_create_response: gcc::ConferenceCreateResponse::new(self.user_channel_id, server_blocks) + .map_err(ConnectorError::decode)?, called_connect_id: 1, domain_parameters: mcs::DomainParameters::target(), }; diff --git a/crates/ironrdp-connector/src/connection.rs b/crates/ironrdp-connector/src/connection.rs index 3570c8f80f..231adb1a54 100644 --- a/crates/ironrdp-connector/src/connection.rs +++ b/crates/ironrdp-connector/src/connection.rs @@ -345,7 +345,8 @@ impl Sequence for ClientConnector { let client_gcc_blocks = create_gcc_blocks(&self.config, selected_protocol, self.static_channels.values())?; - let connect_initial = mcs::ConnectInitial::with_gcc_blocks(client_gcc_blocks); + let connect_initial = + mcs::ConnectInitial::with_gcc_blocks(client_gcc_blocks).map_err(ConnectorError::decode)?; debug!(message = ?connect_initial, "Send"); @@ -365,9 +366,9 @@ impl Sequence for ClientConnector { debug!(message = ?connect_response, "Received"); - let client_gcc_blocks = &connect_initial.conference_create_request.gcc_blocks; + let client_gcc_blocks = connect_initial.conference_create_request.gcc_blocks(); - let server_gcc_blocks = connect_response.conference_create_response.gcc_blocks; + let server_gcc_blocks = connect_response.conference_create_response.into_gcc_blocks(); if client_gcc_blocks.security == gcc::ClientSecurityData::no_security() && server_gcc_blocks.security != gcc::ServerSecurityData::no_security() diff --git a/crates/ironrdp-pdu/src/basic_output/fast_path.rs b/crates/ironrdp-pdu/src/basic_output/fast_path.rs index 236bfdc05e..20a15dc084 100644 --- a/crates/ironrdp-pdu/src/basic_output/fast_path.rs +++ b/crates/ironrdp-pdu/src/basic_output/fast_path.rs @@ -7,8 +7,8 @@ use ironrdp_core::{ decode_cursor, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeError, DecodeResult, Encode, EncodeResult, InvalidFieldErr as _, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use super::bitmap::BitmapUpdateData; use super::pointer::PointerUpdateData; @@ -136,15 +136,15 @@ impl Encode for FastPathUpdatePdu<'_> { } let mut header = 0u8; - header.set_bits(0..4, self.update_code.to_u8().unwrap()); - header.set_bits(4..6, self.fragmentation.to_u8().unwrap()); + header.set_bits(0..4, self.update_code.as_u8()); + header.set_bits(4..6, self.fragmentation.as_u8()); dst.write_u8(header); if self.compression_flags.is_some() { header.set_bits(6..8, Compression::COMPRESSION_USED.bits()); - let compression_flags_with_type = self.compression_flags.map(|f| f.bits()).unwrap_or(0) - | self.compression_type.and_then(|f| f.to_u8()).unwrap_or(0); + let compression_flags_with_type = + self.compression_flags.map(|f| f.bits()).unwrap_or(0) | self.compression_type.map_or(0, |f| f.as_u8()); dst.write_u8(compression_flags_with_type); } @@ -312,7 +312,8 @@ impl Encode for FastPathUpdate<'_> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum UpdateCode { Orders = 0x0, Bitmap = 0x1, @@ -328,6 +329,16 @@ pub enum UpdateCode { LargePointer = 0xc, } +impl UpdateCode { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u8(self) -> u8 { + self as u8 + } +} + impl From<&FastPathUpdate<'_>> for UpdateCode { fn from(update: &FastPathUpdate<'_>) -> Self { match update { @@ -346,7 +357,8 @@ impl From<&FastPathUpdate<'_>> for UpdateCode { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum Fragmentation { Single = 0x0, Last = 0x1, @@ -354,6 +366,16 @@ pub enum Fragmentation { Next = 0x3, } +impl Fragmentation { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct EncryptionFlags: u8 { diff --git a/crates/ironrdp-pdu/src/basic_output/surface_commands.rs b/crates/ironrdp-pdu/src/basic_output/surface_commands.rs index 2da0461f29..a3de9cfcfc 100644 --- a/crates/ironrdp-pdu/src/basic_output/surface_commands.rs +++ b/crates/ironrdp-pdu/src/basic_output/surface_commands.rs @@ -7,7 +7,7 @@ use ironrdp_core::{ WriteCursor, }; use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_traits::FromPrimitive as _; use crate::geometry::ExclusiveRectangle; @@ -31,7 +31,7 @@ impl Encode for SurfaceCommand<'_> { ensure_size!(in: dst, size: self.size()); let cmd_type = SurfaceCommandType::from(self); - dst.write_u16(cmd_type.to_u16().unwrap()); + dst.write_u16(cmd_type.as_u16()); match self { Self::SetSurfaceBits(pdu) | Self::StreamSurfaceBits(pdu) => pdu.encode(dst), @@ -324,7 +324,7 @@ impl Decode<'_> for BitmapDataHeader { } } -#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive)] #[repr(u16)] enum SurfaceCommandType { SetSurfaceBits = 0x01, @@ -332,6 +332,16 @@ enum SurfaceCommandType { StreamSurfaceBits = 0x06, } +impl SurfaceCommandType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl From<&SurfaceCommand<'_>> for SurfaceCommandType { fn from(command: &SurfaceCommand<'_>) -> Self { match command { diff --git a/crates/ironrdp-pdu/src/codecs/rfx.rs b/crates/ironrdp-pdu/src/codecs/rfx.rs index 32a279d006..2d9a2f02f6 100644 --- a/crates/ironrdp-pdu/src/codecs/rfx.rs +++ b/crates/ironrdp-pdu/src/codecs/rfx.rs @@ -5,8 +5,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::rdp::capability_sets::{RfxCaps, RfxCapset}; @@ -187,7 +187,7 @@ impl Encode for BlockHeader { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(self.ty.to_u16().unwrap()); + dst.write_u16(self.ty.as_u16()); dst.write_u32(cast_length!("data len", self.data_length)?); Ok(()) @@ -307,7 +307,7 @@ impl<'de> Decode<'de> for FrameAcknowledgePdu { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] #[repr(u16)] pub enum BlockType { Tile = 0xCAC3, @@ -330,4 +330,12 @@ impl BlockType { BlockType::Context | BlockType::FrameBegin | BlockType::FrameEnd | BlockType::Region | BlockType::Extension ) } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } } diff --git a/crates/ironrdp-pdu/src/codecs/rfx/data_messages.rs b/crates/ironrdp-pdu/src/codecs/rfx/data_messages.rs index 8e33da9a37..fcb2b20323 100644 --- a/crates/ironrdp-pdu/src/codecs/rfx/data_messages.rs +++ b/crates/ironrdp-pdu/src/codecs/rfx/data_messages.rs @@ -4,8 +4,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::codecs::rfx::Block; @@ -48,7 +48,7 @@ impl Encode for ContextPdu { properties.set_bits(0..3, self.flags.bits()); properties.set_bits(3..5, COLOR_CONVERSION_ICT); properties.set_bits(5..9, CLW_XFORM_DWT_53_A); - properties.set_bits(9..13, self.entropy_algorithm.to_u16().unwrap()); + properties.set_bits(9..13, self.entropy_algorithm.as_u16()); properties.set_bits(13..15, SCALAR_QUANTIZATION); properties.set_bit(15, false); // reserved dst.write_u16(properties); @@ -297,7 +297,7 @@ impl Encode for TileSetPdu<'_> { properties.set_bits(1..4, OperatingMode::empty().bits()); // The decoder MUST ignore this flag properties.set_bits(4..6, COLOR_CONVERSION_ICT); properties.set_bits(6..10, CLW_XFORM_DWT_53_A); - properties.set_bits(10..14, self.entropy_algorithm.to_u16().unwrap()); + properties.set_bits(10..14, self.entropy_algorithm.as_u16()); properties.set_bits(14..16, SCALAR_QUANTIZATION); dst.write_u16(properties); @@ -666,13 +666,23 @@ impl<'de> Decode<'de> for Tile<'de> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] #[repr(u16)] pub enum EntropyAlgorithm { Rlgr1 = 0x01, Rlgr3 = 0x04, } +impl EntropyAlgorithm { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct OperatingMode: u16 { diff --git a/crates/ironrdp-pdu/src/gcc.rs b/crates/ironrdp-pdu/src/gcc.rs index b7c8cc5d7e..27f787ae1d 100644 --- a/crates/ironrdp-pdu/src/gcc.rs +++ b/crates/ironrdp-pdu/src/gcc.rs @@ -4,8 +4,8 @@ use ironrdp_core::{ cast_length, decode, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeErrorKind, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive, ToPrimitive}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; use thiserror::Error; use crate::PduError; @@ -86,26 +86,30 @@ impl Encode for ClientGccBlocks { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - UserDataHeader::encode(dst, ClientGccType::CoreData, &self.core)?; - UserDataHeader::encode(dst, ClientGccType::SecurityData, &self.security)?; + UserDataHeader::encode(dst, ClientGccType::CoreData.as_u16(), &self.core)?; + UserDataHeader::encode(dst, ClientGccType::SecurityData.as_u16(), &self.security)?; if let Some(ref network) = self.network { - UserDataHeader::encode(dst, ClientGccType::NetworkData, network)?; + UserDataHeader::encode(dst, ClientGccType::NetworkData.as_u16(), network)?; } if let Some(ref cluster) = self.cluster { - UserDataHeader::encode(dst, ClientGccType::ClusterData, cluster)?; + UserDataHeader::encode(dst, ClientGccType::ClusterData.as_u16(), cluster)?; } if let Some(ref monitor) = self.monitor { - UserDataHeader::encode(dst, ClientGccType::MonitorData, monitor)?; + UserDataHeader::encode(dst, ClientGccType::MonitorData.as_u16(), monitor)?; } if let Some(ref message_channel) = self.message_channel { - UserDataHeader::encode(dst, ClientGccType::MessageChannelData, message_channel)?; + UserDataHeader::encode(dst, ClientGccType::MessageChannelData.as_u16(), message_channel)?; } if let Some(ref multi_transport_channel) = self.multi_transport_channel { - UserDataHeader::encode(dst, ClientGccType::MultiTransportChannelData, multi_transport_channel)?; + UserDataHeader::encode( + dst, + ClientGccType::MultiTransportChannelData.as_u16(), + multi_transport_channel, + )?; } if let Some(ref monitor_extended) = self.monitor_extended { - UserDataHeader::encode(dst, ClientGccType::MonitorExtendedData, monitor_extended)?; + UserDataHeader::encode(dst, ClientGccType::MonitorExtendedData.as_u16(), monitor_extended)?; } Ok(()) @@ -202,15 +206,19 @@ impl ServerGccBlocks { impl Encode for ServerGccBlocks { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - UserDataHeader::encode(dst, ServerGccType::CoreData, &self.core)?; - UserDataHeader::encode(dst, ServerGccType::NetworkData, &self.network)?; - UserDataHeader::encode(dst, ServerGccType::SecurityData, &self.security)?; + UserDataHeader::encode(dst, ServerGccType::CoreData.as_u16(), &self.core)?; + UserDataHeader::encode(dst, ServerGccType::NetworkData.as_u16(), &self.network)?; + UserDataHeader::encode(dst, ServerGccType::SecurityData.as_u16(), &self.security)?; if let Some(ref message_channel) = self.message_channel { - UserDataHeader::encode(dst, ServerGccType::MessageChannelData, message_channel)?; + UserDataHeader::encode(dst, ServerGccType::MessageChannelData.as_u16(), message_channel)?; } if let Some(ref multi_transport_channel) = self.multi_transport_channel { - UserDataHeader::encode(dst, ServerGccType::MultiTransportChannelData, multi_transport_channel)?; + UserDataHeader::encode( + dst, + ServerGccType::MultiTransportChannelData.as_u16(), + multi_transport_channel, + )?; } Ok(()) @@ -265,7 +273,7 @@ impl<'de> Decode<'de> for ServerGccBlocks { } #[repr(u16)] -#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, FromPrimitive)] pub enum ClientGccType { CoreData = 0xC001, SecurityData = 0xC002, @@ -277,8 +285,18 @@ pub enum ClientGccType { MultiTransportChannelData = 0xC00A, } +impl ClientGccType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, FromPrimitive)] pub enum ServerGccType { CoreData = 0x0C01, SecurityData = 0x0C02, @@ -287,6 +305,16 @@ pub enum ServerGccType { MultiTransportChannelData = 0x0C08, } +impl ServerGccType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u16(self) -> u16 { + self as u16 + } +} + #[derive(Debug)] pub struct UserDataHeader; @@ -295,12 +323,12 @@ impl UserDataHeader { pub fn encode(dst: &mut WriteCursor<'_>, block_type: T, block: &B) -> EncodeResult<()> where - T: ToPrimitive, + T: Into, B: Encode, { ensure_fixed_part_size!(in: dst); - dst.write_u16(block_type.to_u16().unwrap()); + dst.write_u16(block_type.into()); dst.write_u16(cast_length!("blockLen", block.size() + USER_DATA_HEADER_SIZE)?); block.encode(dst)?; diff --git a/crates/ironrdp-pdu/src/gcc/cluster_data.rs b/crates/ironrdp-pdu/src/gcc/cluster_data.rs index cc94e44749..bda5357d70 100644 --- a/crates/ironrdp-pdu/src/gcc/cluster_data.rs +++ b/crates/ironrdp-pdu/src/gcc/cluster_data.rs @@ -4,8 +4,8 @@ use bitflags::bitflags; use ironrdp_core::{ ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use thiserror::Error; const REDIRECTION_VERSION_MASK: u32 = 0x0000_003C; @@ -30,7 +30,7 @@ impl Encode for ClientClusterData { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - let flags_with_version = self.flags.bits() | (self.redirection_version.to_u32().unwrap() << 2); + let flags_with_version = self.flags.bits() | (self.redirection_version.as_u32() << 2); dst.write_u32(flags_with_version); dst.write_u32(self.redirected_session_id); @@ -77,7 +77,8 @@ bitflags! { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum RedirectionVersion { V1 = 0, V2 = 1, @@ -87,6 +88,16 @@ pub enum RedirectionVersion { V6 = 5, } +impl RedirectionVersion { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[derive(Debug, Error)] pub enum ClusterDataError { #[error("IO error")] diff --git a/crates/ironrdp-pdu/src/gcc/conference_create.rs b/crates/ironrdp-pdu/src/gcc/conference_create.rs index e3d764f61d..9c5e3ee4f8 100644 --- a/crates/ironrdp-pdu/src/gcc/conference_create.rs +++ b/crates/ironrdp-pdu/src/gcc/conference_create.rs @@ -26,11 +26,34 @@ const CONFERENCE_NAME: &[u8] = b"1"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ConferenceCreateRequest { - pub gcc_blocks: ClientGccBlocks, + /// INVARIANT: `gcc_blocks.size() <= u16::MAX - CONFERENCE_REQUEST_CONNECT_PDU_SIZE` + gcc_blocks: ClientGccBlocks, } impl ConferenceCreateRequest { const NAME: &'static str = "ConferenceCreateRequest"; + + pub fn new(gcc_blocks: ClientGccBlocks) -> DecodeResult { + // Ensure the invariant on gcc_blocks.size() is respected. + check_invariant(gcc_blocks.size() <= usize::from(u16::MAX) - CONFERENCE_REQUEST_CONNECT_PDU_SIZE).ok_or_else( + || { + invalid_field_err!( + "gcc_blocks", + "gcc_blocks.size() + CONFERENCE_REQUEST_CONNECT_PDU_SIZE > u16::MAX" + ) + }, + )?; + + Ok(Self { gcc_blocks }) + } + + pub fn gcc_blocks(&self) -> &ClientGccBlocks { + &self.gcc_blocks + } + + pub fn into_gcc_blocks(self) -> ClientGccBlocks { + self.gcc_blocks + } } impl Encode for ConferenceCreateRequest { @@ -84,16 +107,16 @@ impl Encode for ConferenceCreateRequest { fn size(&self) -> usize { let gcc_blocks_buffer_length = self.gcc_blocks.size(); - let req_length: DecodeResult = cast_length!( - "gccBlocksLen", - CONFERENCE_REQUEST_CONNECT_PDU_SIZE + gcc_blocks_buffer_length - ); - let length: DecodeResult = cast_length!("gccBlocksLen", gcc_blocks_buffer_length); + let req_length = u16::try_from(CONFERENCE_REQUEST_CONNECT_PDU_SIZE + gcc_blocks_buffer_length) + .expect("per the invariant on self.gcc_blocks, this cast is infallible"); + let length = u16::try_from(gcc_blocks_buffer_length) + .expect("per the invariant on self.gcc_blocks, this cast is infallible"); + per::CHOICE_SIZE + CONFERENCE_REQUEST_OBJECT_ID.len() - + per::sizeof_length(req_length.unwrap()) + + per::sizeof_length(req_length) + CONFERENCE_REQUEST_CONNECT_PDU_SIZE - + per::sizeof_length(length.unwrap()) + + per::sizeof_length(length) + gcc_blocks_buffer_length } } @@ -169,18 +192,41 @@ impl<'de> Decode<'de> for ConferenceCreateRequest { let (_gcc_blocks_buffer_length, _) = per::read_length(src).map_err(|e| other_err!("len", source: e))?; let gcc_blocks = ClientGccBlocks::decode(src)?; - Ok(Self { gcc_blocks }) + Self::new(gcc_blocks) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ConferenceCreateResponse { - pub user_id: u16, - pub gcc_blocks: ServerGccBlocks, + user_id: u16, + /// INVARIANT: `gcc_blocks.size() <= u16::MAX - CONFERENCE_RESPONSE_CONNECT_PDU_SIZE` + gcc_blocks: ServerGccBlocks, } impl ConferenceCreateResponse { const NAME: &'static str = "ConferenceCreateResponse"; + + pub fn new(user_id: u16, gcc_blocks: ServerGccBlocks) -> DecodeResult { + // Ensure the invariant on gcc_blocks.size() is respected. + check_invariant(gcc_blocks.size() <= usize::from(u16::MAX) - CONFERENCE_RESPONSE_CONNECT_PDU_SIZE).ok_or_else( + || { + invalid_field_err!( + "gcc_blocks", + "gcc_blocks.size() + CONFERENCE_REQUEST_CONNECT_PDU_SIZE > u16::MAX" + ) + }, + )?; + + Ok(Self { user_id, gcc_blocks }) + } + + pub fn gcc_blocks(&self) -> &ServerGccBlocks { + &self.gcc_blocks + } + + pub fn into_gcc_blocks(self) -> ServerGccBlocks { + self.gcc_blocks + } } impl Encode for ConferenceCreateResponse { @@ -197,6 +243,8 @@ impl Encode for ConferenceCreateResponse { dst, cast_length!( "gccBlocksLen", + // FIXME: It seems that the addition of 1 here is a bug. + // The fuzzing is not failing because this length is ignored. gcc_blocks_buffer_length + CONFERENCE_RESPONSE_CONNECT_PDU_SIZE + 1 )?, ); @@ -219,7 +267,7 @@ impl Encode for ConferenceCreateResponse { ) .map_err(|e| other_err!("server-to-client", source: e))?; // H221NonStandardIdentifier (octet string) - per::write_length(dst, gcc_blocks_buffer_length as u16); + per::write_length(dst, cast_length!("gccBlocksLen", gcc_blocks_buffer_length)?); self.gcc_blocks.encode(dst)?; Ok(()) @@ -231,16 +279,16 @@ impl Encode for ConferenceCreateResponse { fn size(&self) -> usize { let gcc_blocks_buffer_length = self.gcc_blocks.size(); - let req_length: DecodeResult = cast_length!( - "gccBlocksLen", - CONFERENCE_RESPONSE_CONNECT_PDU_SIZE + gcc_blocks_buffer_length - ); - let length: DecodeResult = cast_length!("gccBlocksLen", gcc_blocks_buffer_length); + let req_length = u16::try_from(CONFERENCE_RESPONSE_CONNECT_PDU_SIZE + gcc_blocks_buffer_length) + .expect("per the invariant on self.gcc_blocks, this cast is infallible"); + let length = u16::try_from(gcc_blocks_buffer_length) + .expect("per the invariant on self.gcc_blocks, this cast is infallible"); + per::CHOICE_SIZE + CONFERENCE_REQUEST_OBJECT_ID.len() - + per::sizeof_length(req_length.unwrap()) + + per::sizeof_length(req_length) + CONFERENCE_RESPONSE_CONNECT_PDU_SIZE - + per::sizeof_length(length.unwrap()) + + per::sizeof_length(length) + gcc_blocks_buffer_length } } @@ -315,6 +363,13 @@ impl<'de> Decode<'de> for ConferenceCreateResponse { let (_gcc_blocks_buffer_length, _) = per::read_length(src).map_err(|e| other_err!("len", source: e))?; let gcc_blocks = ServerGccBlocks::decode(src)?; - Ok(Self { user_id, gcc_blocks }) + Self::new(user_id, gcc_blocks) } } + +/// Use this when establishing invariants. +#[inline] +#[must_use] +fn check_invariant(condition: bool) -> Option<()> { + condition.then_some(()) +} diff --git a/crates/ironrdp-pdu/src/gcc/core_data/client.rs b/crates/ironrdp-pdu/src/gcc/core_data/client.rs index e207086b1e..1437eb54dd 100644 --- a/crates/ironrdp-pdu/src/gcc/core_data/client.rs +++ b/crates/ironrdp-pdu/src/gcc/core_data/client.rs @@ -3,8 +3,8 @@ use ironrdp_core::{ ensure_fixed_part_size, ensure_size, invalid_field_err, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use tap::Pipe as _; use super::{RdpVersion, VERSION_SIZE}; @@ -108,13 +108,13 @@ impl Encode for ClientCoreData { dst.write_u32(self.version.0); dst.write_u16(self.desktop_width); dst.write_u16(self.desktop_height); - dst.write_u16(self.color_depth.to_u16().unwrap()); - dst.write_u16(self.sec_access_sequence.to_u16().unwrap()); + dst.write_u16(self.color_depth.as_u16()); + dst.write_u16(self.sec_access_sequence.as_u16()); dst.write_u32(self.keyboard_layout); dst.write_u32(self.client_build); dst.write_slice(client_name_dst.as_ref()); dst.write_u16(0); // client name UTF-16 null terminator - dst.write_u32(self.keyboard_type.to_u32().unwrap()); + dst.write_u32(self.keyboard_type.as_u32()); dst.write_u32(self.keyboard_subtype); dst.write_u32(self.keyboard_functional_keys_count); dst.write_slice(ime_file_name_dst.as_ref()); @@ -223,7 +223,7 @@ impl Encode for ClientCoreOptionalData { ensure_size!(in: dst, size: self.size()); if let Some(value) = self.post_beta2_color_depth { - dst.write_u16(value.to_u16().unwrap()); + dst.write_u16(value.as_u16()); } if let Some(value) = self.client_product_id { @@ -247,7 +247,7 @@ impl Encode for ClientCoreOptionalData { if self.serial_number.is_none() { return Err(invalid_field_err!("serialNumber", "serialNumber must be present")); } - dst.write_u16(value.to_u16().unwrap()); + dst.write_u16(value.as_u16()); } if let Some(value) = self.supported_color_depths { @@ -285,7 +285,7 @@ impl Encode for ClientCoreOptionalData { if self.dig_product_id.is_none() { return Err(invalid_field_err!("digProductId", "digProductId must be present")); } - dst.write_u8(value.to_u8().unwrap()); + dst.write_u8(value.as_u8()); write_padding!(dst, 1); } @@ -506,7 +506,7 @@ impl From for ClientColorDepth { } #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ColorDepth { Bpp4 = 0xCA00, Bpp8 = 0xCA01, @@ -515,8 +515,18 @@ pub enum ColorDepth { Bpp24 = 0xCA04, } +impl ColorDepth { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Debug, Copy, Clone, FromPrimitive, Eq, Ord, PartialEq, PartialOrd)] pub enum HighColorDepth { Bpp4 = 0x0004, Bpp8 = 0x0008, @@ -525,13 +535,34 @@ pub enum HighColorDepth { Bpp24 = 0x0018, } +impl HighColorDepth { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum SecureAccessSequence { Del = 0xAA03, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +impl SecureAccessSequence { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum KeyboardType { IbmPcXt = 1, OlivettiIco = 2, @@ -542,8 +573,18 @@ pub enum KeyboardType { Japanese = 7, } +impl KeyboardType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u32(self) -> u32 { + self as u32 + } +} + #[repr(u8)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ConnectionType { NotUsed = 0, // not used as ClientEarlyCapabilityFlags::VALID_CONNECTION_TYPE not set Modem = 1, @@ -555,6 +596,16 @@ pub enum ConnectionType { Autodetect = 7, } +impl ConnectionType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct SupportedColorDepths: u16 { diff --git a/crates/ironrdp-pdu/src/gcc/monitor_extended_data.rs b/crates/ironrdp-pdu/src/gcc/monitor_extended_data.rs index 313ece7ab7..f7fc7f9ee8 100644 --- a/crates/ironrdp-pdu/src/gcc/monitor_extended_data.rs +++ b/crates/ironrdp-pdu/src/gcc/monitor_extended_data.rs @@ -2,8 +2,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const MONITOR_COUNT_MAX: usize = 16; const MONITOR_ATTRIBUTE_SIZE: u32 = 20; @@ -95,7 +95,7 @@ impl Encode for ExtendedMonitorInfo { dst.write_u32(self.physical_width); dst.write_u32(self.physical_height); - dst.write_u32(self.orientation.to_u32().unwrap()); + dst.write_u32(self.orientation.as_u32()); dst.write_u32(self.desktop_scale_factor); dst.write_u32(self.device_scale_factor); @@ -132,10 +132,21 @@ impl<'de> Decode<'de> for ExtendedMonitorInfo { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum MonitorOrientation { Landscape = 0, Portrait = 90, LandscapeFlipped = 180, PortraitFlipped = 270, } + +impl MonitorOrientation { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} diff --git a/crates/ironrdp-pdu/src/gcc/security_data.rs b/crates/ironrdp-pdu/src/gcc/security_data.rs index cb269a847f..a33b520bc3 100644 --- a/crates/ironrdp-pdu/src/gcc/security_data.rs +++ b/crates/ironrdp-pdu/src/gcc/security_data.rs @@ -5,8 +5,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use thiserror::Error; const CLIENT_ENCRYPTION_METHODS_SIZE: usize = 4; @@ -100,7 +100,7 @@ impl Encode for ServerSecurityData { ensure_size!(in: dst, size: self.size()); dst.write_u32(self.encryption_method.bits()); - dst.write_u32(self.encryption_level.to_u32().unwrap()); + dst.write_u32(self.encryption_level.as_u32()); if self.encryption_method.is_empty() && self.encryption_level == EncryptionLevel::None { if self.server_random.is_some() || !self.server_cert.is_empty() { @@ -197,7 +197,7 @@ bitflags! { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum EncryptionLevel { None = 0, Low = 1, @@ -206,6 +206,16 @@ pub enum EncryptionLevel { Fips = 4, } +impl EncryptionLevel { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[derive(Debug, Error)] pub enum SecurityDataError { #[error("IO error")] diff --git a/crates/ironrdp-pdu/src/geometry.rs b/crates/ironrdp-pdu/src/geometry.rs index 094619d8d0..b78d87cab0 100644 --- a/crates/ironrdp-pdu/src/geometry.rs +++ b/crates/ironrdp-pdu/src/geometry.rs @@ -149,10 +149,12 @@ impl_rectangle!(InclusiveRectangle); impl_rectangle!(ExclusiveRectangle); impl Rectangle for InclusiveRectangle { + /// INVARIANT: `0 < output (width)` fn width(&self) -> u16 { self.right - self.left + 1 } + /// INVARIANT: `0 < output (height)` fn height(&self) -> u16 { self.bottom - self.top + 1 } diff --git a/crates/ironrdp-pdu/src/input/fast_path.rs b/crates/ironrdp-pdu/src/input/fast_path.rs index 705d4dfaf9..35708b9452 100644 --- a/crates/ironrdp-pdu/src/input/fast_path.rs +++ b/crates/ironrdp-pdu/src/input/fast_path.rs @@ -4,8 +4,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::fast_path::EncryptionFlags; use crate::input::{MousePdu, MouseRelPdu, MouseXPdu}; @@ -88,7 +88,7 @@ impl<'de> Decode<'de> for FastPathInputHeader { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] #[repr(u8)] pub enum FastpathInputEventType { ScanCode = 0x0000, @@ -100,6 +100,16 @@ pub enum FastpathInputEventType { QoeTimestamp = 0x0006, } +impl FastpathInputEventType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum FastPathInputEvent { KeyboardEvent(KeyboardFlags, u8), @@ -132,7 +142,7 @@ impl Encode for FastPathInputEvent { FastPathInputEvent::SyncEvent(flags) => (flags.bits(), FastpathInputEventType::Sync), }; header.set_bits(0..5, flags); - header.set_bits(5..8, code.to_u8().unwrap()); + header.set_bits(5..8, code.as_u8()); dst.write_u8(header); match self { FastPathInputEvent::KeyboardEvent(_, code) => { diff --git a/crates/ironrdp-pdu/src/input/mod.rs b/crates/ironrdp-pdu/src/input/mod.rs index a3d168d209..d947b86452 100644 --- a/crates/ironrdp-pdu/src/input/mod.rs +++ b/crates/ironrdp-pdu/src/input/mod.rs @@ -4,8 +4,8 @@ use ironrdp_core::{ ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use thiserror::Error; pub mod fast_path; @@ -94,7 +94,7 @@ impl Encode for InputEvent { ensure_fixed_part_size!(in: dst); dst.write_u32(0); // event time is ignored by a server - dst.write_u16(InputEventType::from(self).to_u16().unwrap()); + dst.write_u16(InputEventType::from(self).as_u16()); match self { Self::Sync(pdu) => pdu.encode(dst), @@ -146,7 +146,7 @@ impl<'de> Decode<'de> for InputEvent { } } -#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive)] #[repr(u16)] enum InputEventType { Sync = 0x0000, @@ -158,6 +158,16 @@ enum InputEventType { MouseRel = 0x8004, } +impl InputEventType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl From<&InputEvent> for InputEventType { fn from(event: &InputEvent) -> Self { match event { diff --git a/crates/ironrdp-pdu/src/mcs.rs b/crates/ironrdp-pdu/src/mcs.rs index 9e7cc97d56..98690fade3 100644 --- a/crates/ironrdp-pdu/src/mcs.rs +++ b/crates/ironrdp-pdu/src/mcs.rs @@ -847,20 +847,20 @@ pub struct ConnectInitial { } impl ConnectInitial { - pub fn with_gcc_blocks(gcc_blocks: ClientGccBlocks) -> Self { - Self { - conference_create_request: ConferenceCreateRequest { gcc_blocks }, + pub fn with_gcc_blocks(gcc_blocks: ClientGccBlocks) -> DecodeResult { + Ok(Self { + conference_create_request: ConferenceCreateRequest::new(gcc_blocks)?, calling_domain_selector: vec![0x01], called_domain_selector: vec![0x01], upward_flag: true, target_parameters: DomainParameters::target(), min_parameters: DomainParameters::min(), max_parameters: DomainParameters::max(), - } + }) } pub fn channel_names(&self) -> Option> { - self.conference_create_request.gcc_blocks.channel_names() + self.conference_create_request.gcc_blocks().channel_names() } } @@ -873,11 +873,11 @@ pub struct ConnectResponse { impl ConnectResponse { pub fn channel_ids(&self) -> Vec { - self.conference_create_response.gcc_blocks.channel_ids() + self.conference_create_response.gcc_blocks().channel_ids() } pub fn global_channel_id(&self) -> u16 { - self.conference_create_response.gcc_blocks.global_channel_id() + self.conference_create_response.gcc_blocks().global_channel_id() } } diff --git a/crates/ironrdp-pdu/src/per.rs b/crates/ironrdp-pdu/src/per.rs index dc76a2cbe8..a1d2e002c3 100644 --- a/crates/ironrdp-pdu/src/per.rs +++ b/crates/ironrdp-pdu/src/per.rs @@ -105,7 +105,7 @@ pub(crate) fn write_length(dst: &mut WriteCursor<'_>, length: u16) { if length > 0x7f { write_long_length(dst, length); } else { - dst.write_u8(u8::try_from(length).unwrap()); + dst.write_u8(u8::try_from(length).expect("length is guaranteed to fit into u8 due to the prior check")); } } @@ -187,10 +187,10 @@ pub(crate) fn read_u32(src: &mut ReadCursor<'_>) -> Result { pub(crate) fn write_u32(dst: &mut WriteCursor<'_>, value: u32) { if value <= 0xff { write_length(dst, 1); - dst.write_u8(u8::try_from(value).unwrap()); + dst.write_u8(u8::try_from(value).expect("value is guaranteed to fit into u8 due to the prior check")); } else if value <= 0xffff { write_length(dst, 2); - dst.write_u16_be(u16::try_from(value).unwrap()); + dst.write_u16_be(u16::try_from(value).expect("value is guaranteed to fit into u16 due to the prior check")); } else { write_length(dst, 4); dst.write_u32_be(value); diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets.rs b/crates/ironrdp-pdu/src/rdp/capability_sets.rs index 8419f69761..35737ac331 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets.rs @@ -4,8 +4,8 @@ use ironrdp_core::{ cast_length, decode, ensure_fixed_part_size, ensure_size, invalid_field_err, unsupported_value_err, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use thiserror::Error; use crate::{utils, PduError}; @@ -293,7 +293,7 @@ impl Encode for CapabilitySet { match self { CapabilitySet::General(capset) => { - dst.write_u16(CapabilitySetType::General.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::General.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -301,7 +301,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Bitmap(capset) => { - dst.write_u16(CapabilitySetType::Bitmap.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Bitmap.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -309,7 +309,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Order(capset) => { - dst.write_u16(CapabilitySetType::Order.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Order.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -317,7 +317,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::BitmapCache(capset) => { - dst.write_u16(CapabilitySetType::BitmapCache.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::BitmapCache.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -325,7 +325,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::BitmapCacheRev2(capset) => { - dst.write_u16(CapabilitySetType::BitmapCacheRev2.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::BitmapCacheRev2.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -333,7 +333,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Pointer(capset) => { - dst.write_u16(CapabilitySetType::Pointer.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Pointer.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -341,7 +341,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Sound(capset) => { - dst.write_u16(CapabilitySetType::Sound.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Sound.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -349,7 +349,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Input(capset) => { - dst.write_u16(CapabilitySetType::Input.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Input.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -357,7 +357,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Brush(capset) => { - dst.write_u16(CapabilitySetType::Brush.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Brush.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -365,7 +365,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::GlyphCache(capset) => { - dst.write_u16(CapabilitySetType::GlyphCache.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::GlyphCache.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -373,7 +373,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::OffscreenBitmapCache(capset) => { - dst.write_u16(CapabilitySetType::OffscreenBitmapCache.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::OffscreenBitmapCache.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -381,7 +381,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::VirtualChannel(capset) => { - dst.write_u16(CapabilitySetType::VirtualChannel.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::VirtualChannel.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -389,7 +389,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::SurfaceCommands(capset) => { - dst.write_u16(CapabilitySetType::SurfaceCommands.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::SurfaceCommands.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -397,7 +397,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::BitmapCodecs(capset) => { - dst.write_u16(CapabilitySetType::BitmapCodecs.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::BitmapCodecs.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -405,7 +405,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::MultiFragmentUpdate(capset) => { - dst.write_u16(CapabilitySetType::MultiFragmentUpdate.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::MultiFragmentUpdate.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -413,7 +413,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::LargePointer(capset) => { - dst.write_u16(CapabilitySetType::LargePointer.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::LargePointer.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -421,7 +421,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::FrameAcknowledge(capset) => { - dst.write_u16(CapabilitySetType::FrameAcknowledge.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::FrameAcknowledge.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -446,7 +446,7 @@ impl Encode for CapabilitySet { _ => unreachable!(), }; - dst.write_u16(capability_set_type.to_u16().unwrap()); + dst.write_u16(capability_set_type.as_u16()); dst.write_u16(cast_length!( "len", capability_set_buffer.len() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -562,7 +562,8 @@ impl<'de> Decode<'de> for CapabilitySet { } } -#[derive(Copy, Clone, Debug, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Copy, Clone, Debug, FromPrimitive)] enum CapabilitySetType { General = 0x01, Bitmap = 0x02, @@ -595,6 +596,16 @@ enum CapabilitySetType { FrameAcknowledge = 0x1e, } +impl CapabilitySetType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[derive(Debug, Error)] pub enum CapabilitySetsError { #[error("IO error")] diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs index d7242f7198..6c31a3adbe 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs @@ -9,8 +9,8 @@ use ironrdp_core::{ cast_length, decode, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const RFX_ICAP_VERSION: u16 = 0x0100; const RFX_ICAP_TILE_SIZE: u16 = 0x40; @@ -582,7 +582,7 @@ impl Encode for RfxICap { dst.write_u8(self.flags.bits()); dst.write_u8(RFX_ICAP_COLOR_CONVERSION); dst.write_u8(RFX_ICAP_TRANSFORM_BITS); - dst.write_u8(self.entropy_bits.to_u8().unwrap()); + dst.write_u8(self.entropy_bits.as_u8()); Ok(()) } @@ -629,12 +629,19 @@ impl<'de> Decode<'de> for RfxICap { } } -#[derive(PartialEq, Eq, Debug, FromPrimitive, ToPrimitive, Copy, Clone)] +#[repr(u8)] +#[derive(PartialEq, Eq, Debug, FromPrimitive, Copy, Clone)] pub enum EntropyBits { Rlgr1 = 1, Rlgr3 = 4, } +impl EntropyBits { + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CaptureFlags: u32 { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/brush.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/brush.rs index fd2daca9bb..d8c3e6d52b 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/brush.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/brush.rs @@ -4,18 +4,29 @@ mod tests; use ironrdp_core::{ ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const BRUSH_LENGTH: usize = 4; -#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive)] pub enum SupportLevel { Default = 0, Color8x8 = 1, ColorFull = 2, } +impl SupportLevel { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[derive(Debug, PartialEq, Eq, Clone)] pub struct Brush { pub support_level: SupportLevel, @@ -31,7 +42,7 @@ impl Encode for Brush { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(self.support_level.to_u32().unwrap()); + dst.write_u32(self.support_level.as_u32()); Ok(()) } diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache.rs index fe11b0592d..235c2248e3 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache.rs @@ -5,15 +5,16 @@ use ironrdp_core::{ ensure_fixed_part_size, invalid_field_err, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; pub const GLYPH_CACHE_NUM: usize = 10; const GLYPH_CACHE_LENGTH: usize = 48; const CACHE_DEFINITION_LENGTH: usize = 4; -#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive)] pub enum GlyphSupportLevel { None = 0, Partial = 1, @@ -21,6 +22,16 @@ pub enum GlyphSupportLevel { Encode = 3, } +impl GlyphSupportLevel { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[derive(Debug, PartialEq, Eq, Copy, Clone, Default)] pub struct CacheDefinition { pub entries: u16, @@ -86,7 +97,7 @@ impl Encode for GlyphCache { self.frag_cache.encode(dst)?; - dst.write_u16(self.glyph_support_level.to_u16().unwrap()); + dst.write_u16(self.glyph_support_level.as_u16()); write_padding!(dst, 2); Ok(()) diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/input.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/input.rs index ab8341c1d9..51fb0dfc03 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/input.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/input.rs @@ -6,7 +6,7 @@ use ironrdp_core::{ ensure_fixed_part_size, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_traits::FromPrimitive as _; use crate::gcc::{KeyboardType, IME_FILE_NAME_SIZE}; use crate::utils; @@ -53,7 +53,7 @@ impl Encode for Input { dst.write_u32(self.keyboard_layout); let type_buffer = match self.keyboard_type.as_ref() { - Some(value) => value.to_u32().unwrap_or(0), + Some(value) => value.as_u32(), None => 0, }; dst.write_u32(type_buffer); diff --git a/crates/ironrdp-pdu/src/rdp/client_info.rs b/crates/ironrdp-pdu/src/rdp/client_info.rs index a3e90fafdd..f67434aaab 100644 --- a/crates/ironrdp-pdu/src/rdp/client_info.rs +++ b/crates/ironrdp-pdu/src/rdp/client_info.rs @@ -3,11 +3,11 @@ use std::io; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, write_padding, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, write_padding, Decode, DecodeResult, Encode, + EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use thiserror::Error; use crate::utils::CharacterSet; @@ -71,15 +71,30 @@ impl Encode for ClientInfo { dst.write_u32(self.code_page); - let flags_with_compression_type = self.flags.bits() | (self.compression_type.to_u32().unwrap() << 9); + let flags_with_compression_type = self.flags.bits() | (u32::from(self.compression_type.as_u8()) << 9); dst.write_u32(flags_with_compression_type); let domain = self.credentials.domain.clone().unwrap_or_default(); - dst.write_u16(string_len(domain.as_str(), character_set)); - dst.write_u16(string_len(self.credentials.username.as_str(), character_set)); - dst.write_u16(string_len(self.credentials.password.as_str(), character_set)); - dst.write_u16(string_len(self.alternate_shell.as_str(), character_set)); - dst.write_u16(string_len(self.work_dir.as_str(), character_set)); + dst.write_u16(cast_length!( + "domain length", + string_len(domain.as_str(), character_set) + )?); + dst.write_u16(cast_length!( + "username length", + string_len(self.credentials.username.as_str(), character_set) + )?); + dst.write_u16(cast_length!( + "password length", + string_len(self.credentials.password.as_str(), character_set) + )?); + dst.write_u16(cast_length!( + "alternate shell length", + string_len(self.alternate_shell.as_str(), character_set) + )?); + dst.write_u16(cast_length!( + "work dir length", + string_len(self.work_dir.as_str(), character_set) + )?); utils::write_string_to_cursor(dst, domain.as_str(), character_set, true)?; utils::write_string_to_cursor(dst, self.credentials.username.as_str(), character_set, true)?; @@ -111,12 +126,12 @@ impl Encode for ClientInfo { + PASSWORD_LENGTH_SIZE + ALTERNATE_SHELL_LENGTH_SIZE + WORK_DIR_LENGTH_SIZE - + (string_len(domain.as_str(), character_set) + + string_len(domain.as_str(), character_set) + string_len(self.credentials.username.as_str(), character_set) + string_len(self.credentials.password.as_str(), character_set) + string_len(self.alternate_shell.as_str(), character_set) - + string_len(self.work_dir.as_str(), character_set)) as usize - + character_set.to_usize().unwrap() * 5 // null terminator + + string_len(self.work_dir.as_str(), character_set) + + usize::from(character_set.as_u16()) * 5 // null terminator + self.extra_info.size(character_set) } } @@ -141,7 +156,7 @@ impl<'de> Decode<'de> for ClientInfo { }; // Sizes exclude the length of the mandatory null terminator - let nt = character_set.to_usize().unwrap(); + let nt = usize::from(character_set.as_u16()); let domain_size = src.read_u16() as usize + nt; let user_name_size = src.read_u16() as usize + nt; let password_size = src.read_u16() as usize + nt; @@ -234,11 +249,14 @@ impl ExtendedClientInfo { fn encode(&self, dst: &mut WriteCursor<'_>, character_set: CharacterSet) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size(character_set)); + let address_string_len: u16 = cast_length!("address length", string_len(self.address.as_str(), character_set))?; + let dir_string_len: u16 = cast_length!("dir length", string_len(self.dir.as_str(), character_set))?; + dst.write_u16(self.address_family.as_u16()); // // + size of null terminator, which will write in the write_string function - dst.write_u16(string_len(self.address.as_str(), character_set) + character_set.to_u16().unwrap()); + dst.write_u16(address_string_len + character_set.as_u16()); utils::write_string_to_cursor(dst, self.address.as_str(), character_set, true)?; - dst.write_u16(string_len(self.dir.as_str(), character_set) + character_set.to_u16().unwrap()); + dst.write_u16(dir_string_len + character_set.as_u16()); utils::write_string_to_cursor(dst, self.dir.as_str(), character_set, true)?; self.optional_data.encode(dst)?; @@ -248,11 +266,11 @@ impl ExtendedClientInfo { fn size(&self, character_set: CharacterSet) -> usize { CLIENT_ADDRESS_FAMILY_SIZE + CLIENT_ADDRESS_LENGTH_SIZE - + string_len(self.address.as_str(), character_set) as usize - + character_set.to_usize().unwrap() // null terminator + + string_len(self.address.as_str(), character_set) + + usize::from(character_set.as_u16()) // null terminator + CLIENT_DIR_LENGTH_SIZE - + string_len(self.dir.as_str(), character_set) as usize - + character_set.to_usize().unwrap() // null terminator + + string_len(self.dir.as_str(), character_set) + + usize::from(character_set.as_u16()) // null terminator + self.optional_data.size() } } @@ -508,9 +526,9 @@ impl Encode for OptionalSystemTime { dst.write_u16(0); // year if let Some(st) = &self.0 { - dst.write_u16(st.month.to_u16().unwrap()); - dst.write_u16(st.day_of_week.to_u16().unwrap()); - dst.write_u16(st.day.to_u16().unwrap()); + dst.write_u16(st.month.as_u16()); + dst.write_u16(st.day_of_week.as_u16()); + dst.write_u16(st.day.as_u16()); dst.write_u16(st.hour); dst.write_u16(st.minute); dst.write_u16(st.second); @@ -564,7 +582,7 @@ impl<'de> Decode<'de> for OptionalSystemTime { } #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum Month { January = 1, February = 2, @@ -580,8 +598,18 @@ pub enum Month { December = 12, } +impl Month { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum DayOfWeek { Sunday = 0, Monday = 1, @@ -592,8 +620,18 @@ pub enum DayOfWeek { Saturday = 6, } +impl DayOfWeek { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum DayOfWeekOccurrence { First = 1, Second = 2, @@ -602,6 +640,16 @@ pub enum DayOfWeekOccurrence { Last = 5, } +impl DayOfWeekOccurrence { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PerformanceFlags: u32 { @@ -691,7 +739,8 @@ bitflags! { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum CompressionType { K8 = 0, K64 = 1, @@ -699,6 +748,16 @@ pub enum CompressionType { Rdp61 = 3, } +impl CompressionType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u8(self) -> u8 { + self as u8 + } +} + #[derive(Debug, Error)] pub enum ClientInfoError { #[error("IO error")] @@ -723,10 +782,11 @@ impl From for ClientInfoError { } } -fn string_len(value: &str, character_set: CharacterSet) -> u16 { +fn string_len(value: &str, character_set: CharacterSet) -> usize { match character_set { - CharacterSet::Ansi => u16::try_from(value.len()).unwrap(), - CharacterSet::Unicode => u16::try_from(value.encode_utf16().count() * 2).unwrap(), + CharacterSet::Ansi => value.len(), + // TODO: Use UTF-16 helper. + CharacterSet::Unicode => value.encode_utf16().count() * 2, } } diff --git a/crates/ironrdp-pdu/src/rdp/finalization_messages.rs b/crates/ironrdp-pdu/src/rdp/finalization_messages.rs index a1c9fb7088..480066ba85 100644 --- a/crates/ironrdp-pdu/src/rdp/finalization_messages.rs +++ b/crates/ironrdp-pdu/src/rdp/finalization_messages.rs @@ -3,8 +3,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::gcc; @@ -76,7 +76,7 @@ impl Encode for ControlPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(self.action.to_u16().unwrap()); + dst.write_u16(self.action.as_u16()); dst.write_u16(self.grant_id); dst.write_u32(self.control_id); @@ -230,7 +230,7 @@ impl<'de> Decode<'de> for MonitorLayoutPdu { } #[repr(u16)] -#[derive(Debug, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ControlAction { RequestControl = 1, GrantedControl = 2, @@ -238,6 +238,16 @@ pub enum ControlAction { Cooperate = 4, } +impl ControlAction { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct SequenceFlags: u16 { diff --git a/crates/ironrdp-pdu/src/rdp/headers.rs b/crates/ironrdp-pdu/src/rdp/headers.rs index 5632c1d15b..f469f52c63 100644 --- a/crates/ironrdp-pdu/src/rdp/headers.rs +++ b/crates/ironrdp-pdu/src/rdp/headers.rs @@ -3,8 +3,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, not_enough_bytes_err, other_err, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::codecs::rfx::FrameAcknowledgePdu; use crate::input::InputEventPdu; @@ -89,7 +89,7 @@ impl Encode for ShareControlHeader { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - let pdu_type_with_version = PROTOCOL_VERSION | self.share_control_pdu.share_header_type().to_u16().unwrap(); + let pdu_type_with_version = PROTOCOL_VERSION | self.share_control_pdu.share_header_type().as_u16(); dst.write_u16(cast_length!( "len", @@ -249,10 +249,10 @@ impl Encode for ShareDataHeader { ensure_size!(in: dst, size: self.size()); if self.compression_flags.is_empty() { - let compression_flags_with_type = self.compression_flags.bits() | self.compression_type.to_u8().unwrap(); + let compression_flags_with_type = self.compression_flags.bits() | self.compression_type.as_u8(); write_padding!(dst, 1); - dst.write_u8(self.stream_priority.to_u8().unwrap()); + dst.write_u8(self.stream_priority.as_u8()); dst.write_u16(cast_length!( "uncompressedLength", self.share_data_pdu.size() @@ -260,7 +260,7 @@ impl Encode for ShareDataHeader { + COMPRESSION_TYPE_FIELD_SIZE + COMPRESSED_LENGTH_FIELD_SIZE )?); - dst.write_u8(self.share_data_pdu.share_header_type().to_u8().unwrap()); + dst.write_u8(self.share_data_pdu.share_header_type().as_u8()); dst.write_u8(compression_flags_with_type); dst.write_u16(0); // compressed length @@ -515,7 +515,8 @@ bitflags! { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum StreamPriority { Undefined = 0, Low = 1, @@ -523,7 +524,18 @@ pub enum StreamPriority { High = 4, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +impl StreamPriority { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ShareControlPduType { DemandActivePdu = 0x1, ConfirmActivePdu = 0x3, @@ -532,7 +544,17 @@ pub enum ShareControlPduType { ServerRedirect = 0xa, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +impl ShareControlPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] #[repr(u8)] pub enum ShareDataPduType { Update = 0x02, @@ -562,6 +584,16 @@ pub enum ShareDataPduType { FrameAcknowledgePdu = 0x38, } +impl ShareDataPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CompressionFlags: u8 { diff --git a/crates/ironrdp-pdu/src/rdp/server_error_info.rs b/crates/ironrdp-pdu/src/rdp/server_error_info.rs index 23c227e611..01b74beeac 100644 --- a/crates/ironrdp-pdu/src/rdp/server_error_info.rs +++ b/crates/ironrdp-pdu/src/rdp/server_error_info.rs @@ -1,8 +1,8 @@ use ironrdp_core::{ ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive, ToPrimitive}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServerSetErrorInfoPdu(pub ErrorInfo); @@ -17,7 +17,7 @@ impl Encode for ServerSetErrorInfoPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(self.0.to_u32().unwrap()); + dst.write_u32(self.0.as_u32()); Ok(()) } @@ -66,6 +66,15 @@ impl ErrorInfo { Self::RdpSpecificCode(c) => format!("[RDP specific code]: {}", c.description()), } } + + fn as_u32(self) -> u32 { + match self { + Self::ProtocolIndependentCode(c) => c.as_u32(), + Self::ProtocolIndependentLicensingCode(c) => c.as_u32(), + Self::ProtocolIndependentConnectionBrokerCode(c) => c.as_u32(), + Self::RdpSpecificCode(c) => c.as_u32(), + } + } } impl FromPrimitive for ErrorInfo { @@ -94,27 +103,8 @@ impl FromPrimitive for ErrorInfo { } } -impl ToPrimitive for ErrorInfo { - fn to_i64(&self) -> Option { - match self { - Self::ProtocolIndependentCode(c) => c.to_i64(), - Self::ProtocolIndependentLicensingCode(c) => c.to_i64(), - Self::ProtocolIndependentConnectionBrokerCode(c) => c.to_i64(), - Self::RdpSpecificCode(c) => c.to_i64(), - } - } - - fn to_u64(&self) -> Option { - match self { - Self::ProtocolIndependentCode(c) => c.to_u64(), - Self::ProtocolIndependentLicensingCode(c) => c.to_u64(), - Self::ProtocolIndependentConnectionBrokerCode(c) => c.to_u64(), - Self::RdpSpecificCode(c) => c.to_u64(), - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ProtocolIndependentCode { None = 0x0000_0000, RpcInitiatedDisconnect = 0x0000_0001, @@ -159,9 +149,18 @@ impl ProtocolIndependentCode { Self::ServerCsrssCrash => "The CSRSS process running in the remote session terminated unexpectedly", } } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u32(self) -> u32 { + self as u32 + } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ProtocolIndependentLicensingCode { Internal = 0x0000_0100, NoLicenseServer = 0x0000_0101, @@ -194,9 +193,18 @@ impl ProtocolIndependentLicensingCode { Self::NoRemoteConnections => "The remote computer is not licensed to accept remote connections", } } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ProtocolIndependentConnectionBrokerCode { DestinationNotFound = 0x0000_0400, LoadingDestination = 0x0000_0402, @@ -227,9 +235,18 @@ impl ProtocolIndependentConnectionBrokerCode { Self::SessionOnlineVmSessmonFailed => "A session monitoring error occurred while the target endpoint (a virtual machine) was being started", } } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum RdpSpecificCode { UnknownPduType2 = 0x0000_10C9, UnknownPduType = 0x0000_10CA, @@ -394,6 +411,10 @@ impl RdpSpecificCode { Self::DecryptFailed2 => "Unencrypted data was encountered in a protocol stream which is meant to be encrypted with Standard RDP Security mechanisms", } } + + fn as_u32(self) -> u32 { + self as u32 + } } #[cfg(test)] diff --git a/crates/ironrdp-pdu/src/rdp/server_license.rs b/crates/ironrdp-pdu/src/rdp/server_license.rs index 2ec3dd6d36..ee1b2bf0d2 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license.rs @@ -6,8 +6,8 @@ use ironrdp_core::{ EncodeResult, ReadCursor, WriteCursor, }; use md5::Digest as _; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use thiserror::Error; use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags, BASIC_SECURITY_HEADER_SIZE}; @@ -78,9 +78,9 @@ impl Encode for LicenseHeader { self.security_header.encode(dst)?; - let flags_with_version = self.preamble_flags.bits() | self.preamble_version.to_u8().unwrap(); + let flags_with_version = self.preamble_flags.bits() | self.preamble_version.as_u8(); - dst.write_u8(self.preamble_message_type.to_u8().unwrap()); + dst.write_u8(self.preamble_message_type.as_u8()); dst.write_u8(flags_with_version); dst.write_u16(self.preamble_message_size); // msg size @@ -135,7 +135,7 @@ impl<'de> Decode<'de> for LicenseHeader { /// /// [2.2.1.12.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/73170ca2-5f82-4a2d-9d1b-b439f3d8dadc #[repr(u8)] -#[derive(Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, PartialEq, Eq, FromPrimitive, Copy, Clone)] pub enum PreambleType { LicenseRequest = 0x01, PlatformChallenge = 0x02, @@ -147,6 +147,16 @@ pub enum PreambleType { ErrorAlert = 0xff, } +impl PreambleType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PreambleFlags: u8 { @@ -154,12 +164,23 @@ bitflags! { } } -#[derive(Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, PartialEq, Eq, FromPrimitive, Copy, Clone)] pub enum PreambleVersion { V2 = 2, // RDP 4.0 V3 = 3, // RDP 5.0, 5.1, 5.2, 6.0, 6.1, 7.0, 7.1, 8.0, 8.1, 10.0, 10.1, 10.2, 10.3, 10.4, and 10.5 } +impl PreambleVersion { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BlobType(u16); @@ -184,6 +205,8 @@ pub enum ServerLicenseError { IOError(#[from] io::Error), #[error("UTF-8 error: {0}")] Utf8Error(#[from] std::string::FromUtf8Error), + #[error("DER error: {0}")] + DerError(#[from] pkcs1::der::Error), #[error("invalid preamble field: {0}")] InvalidPreamble(String), #[error("invalid preamble message type field")] diff --git a/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response.rs b/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response.rs index 3a6e8cb39b..00ce7c64c2 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response.rs @@ -8,8 +8,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use super::{ BasicSecurityHeader, BasicSecurityHeaderFlags, BlobHeader, BlobType, LicenseEncryptionData, LicenseHeader, @@ -54,8 +54,8 @@ impl ClientPlatformChallengeResponse { let mut challenge_response_data = vec![0u8; RESPONSE_DATA_STATIC_FIELDS_SIZE]; challenge_response_data.write_u16::(RESPONSE_DATA_VERSION)?; - challenge_response_data.write_u16::(ClientType::Other.to_u16().unwrap())?; - challenge_response_data.write_u16::(LicenseDetailLevel::Detail.to_u16().unwrap())?; + challenge_response_data.write_u16::(ClientType::Other.as_u16())?; + challenge_response_data.write_u16::(LicenseDetailLevel::Detail.as_u16())?; challenge_response_data.write_u16::(decrypted_challenge.len() as u16)?; challenge_response_data.write_all(&decrypted_challenge)?; @@ -162,7 +162,8 @@ impl ClientPlatformChallengeResponse { } } -#[derive(Debug, PartialEq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive)] pub enum ClientType { Win32 = 0x0100, Win16 = 0x0200, @@ -170,13 +171,34 @@ pub enum ClientType { Other = 0xff00, } -#[derive(Debug, PartialEq, FromPrimitive, ToPrimitive)] +impl ClientType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive)] pub enum LicenseDetailLevel { Simple = 1, Moderate = 2, Detail = 3, } +impl LicenseDetailLevel { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[derive(Debug, PartialEq)] pub struct PlatformChallengeResponseData { pub client_type: ClientType, @@ -195,8 +217,8 @@ impl Encode for PlatformChallengeResponseData { ensure_size!(in: dst, size: self.size()); dst.write_u16(RESPONSE_DATA_VERSION); - dst.write_u16(self.client_type.to_u16().unwrap()); - dst.write_u16(self.license_detail_level.to_u16().unwrap()); + dst.write_u16(self.client_type.as_u16()); + dst.write_u16(self.license_detail_level.as_u16()); dst.write_u16(cast_length!("len", self.challenge.len())?); dst.write_slice(&self.challenge); diff --git a/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message.rs b/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message.rs index dd2a07b7ee..d566726004 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message.rs @@ -5,8 +5,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode as _, DecodeResult, Encode as _, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use super::{BlobHeader, BlobType, LicenseHeader, PreambleFlags, PreambleVersion, BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE}; use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags, BASIC_SECURITY_HEADER_SIZE}; @@ -61,8 +61,8 @@ impl LicensingErrorMessage { self.license_header.encode(dst)?; - dst.write_u32(self.error_code.to_u32().unwrap()); - dst.write_u32(self.state_transition.to_u32().unwrap()); + dst.write_u32(self.error_code.as_u32()); + dst.write_u32(self.state_transition.as_u32()); BlobHeader::new(BlobType::ERROR, self.error_info.len()).encode(dst)?; dst.write_slice(&self.error_info); @@ -107,7 +107,8 @@ impl LicensingErrorMessage { } } -#[derive(Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, PartialEq, Eq, FromPrimitive, Copy, Clone)] pub enum LicenseErrorCode { InvalidServerCertificate = 0x01, NoLicense = 0x02, @@ -120,10 +121,31 @@ pub enum LicenseErrorCode { InvalidFieldLen = 0x0c, } -#[derive(Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +impl LicenseErrorCode { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + +#[repr(u32)] +#[derive(Debug, PartialEq, Eq, FromPrimitive, Copy, Clone)] pub enum LicensingStateTransition { TotalAbort = 1, NoTransition = 2, ResetPhaseToStart = 3, ResendLastMessage = 4, } + +impl LicensingStateTransition { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} diff --git a/crates/ironrdp-pdu/src/rdp/server_license/server_license_request.rs b/crates/ironrdp-pdu/src/rdp/server_license/server_license_request.rs index 2fd7948d3a..9adf8ff7ca 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/server_license_request.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/server_license_request.rs @@ -221,11 +221,11 @@ impl ServerCertificate { let public_exponent = certificate.public_key.public_exponent.to_le_bytes(); let rsa_public_key = pkcs1::RsaPublicKey { - modulus: pkcs1::UintRef::new(&certificate.public_key.modulus).unwrap(), - public_exponent: pkcs1::UintRef::new(&public_exponent).unwrap(), + modulus: pkcs1::UintRef::new(&certificate.public_key.modulus)?, + public_exponent: pkcs1::UintRef::new(&public_exponent)?, }; - let public_key = pkcs1::der::Encode::to_der(&rsa_public_key).unwrap(); + let public_key = pkcs1::der::Encode::to_der(&rsa_public_key)?; Ok(public_key) } diff --git a/crates/ironrdp-pdu/src/rdp/session_info.rs b/crates/ironrdp-pdu/src/rdp/session_info.rs index bb347801f0..b64c886999 100644 --- a/crates/ironrdp-pdu/src/rdp/session_info.rs +++ b/crates/ironrdp-pdu/src/rdp/session_info.rs @@ -4,8 +4,8 @@ use ironrdp_core::{ ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use thiserror::Error; use crate::PduError; @@ -41,7 +41,7 @@ impl Encode for SaveSessionInfoPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(self.info_type.to_u32().unwrap()); + dst.write_u32(self.info_type.as_u32()); match self.info_data { InfoData::LogonInfoV1(ref info_v1) => { info_v1.encode(dst)?; @@ -101,7 +101,7 @@ impl<'de> Decode<'de> for SaveSessionInfoPdu { } #[repr(u32)] -#[derive(Debug, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum InfoType { Logon = 0x0000_0000, LogonLong = 0x0000_0001, @@ -109,6 +109,16 @@ pub enum InfoType { LogonExtended = 0x0000_0003, } +impl InfoType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum InfoData { LogonInfoV1(LogonInfoVersion1), diff --git a/crates/ironrdp-pdu/src/rdp/session_info/logon_extended.rs b/crates/ironrdp-pdu/src/rdp/session_info/logon_extended.rs index b9fbb7a665..47c195dcff 100644 --- a/crates/ironrdp-pdu/src/rdp/session_info/logon_extended.rs +++ b/crates/ironrdp-pdu/src/rdp/session_info/logon_extended.rs @@ -3,8 +3,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const LOGON_EX_LENGTH_FIELD_SIZE: usize = 2; const LOGON_EX_FLAGS_FIELD_SIZE: usize = 4; @@ -172,7 +172,7 @@ impl Encode for LogonErrorsInfo { ensure_fixed_part_size!(in: dst); dst.write_u32(LOGON_ERRORS_INFO_SIZE as u32); - dst.write_u32(self.error_type.to_u32().unwrap()); + dst.write_u32(self.error_type.as_u32()); dst.write_u32(self.error_data.to_u32()); Ok(()) @@ -213,7 +213,7 @@ bitflags! { } #[repr(u32)] -#[derive(Debug, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum LogonErrorNotificationType { SessionBusyOptions = 0xFFFF_FFF8, DisconnectRefused = 0xFFFF_FFF9, @@ -225,8 +225,18 @@ pub enum LogonErrorNotificationType { AccessDenied = 0xFFFF_FFFF, } +impl LogonErrorNotificationType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[repr(u32)] -#[derive(Debug, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum LogonErrorNotificationDataErrorCode { FailedBadPassword = 0x0000_0000, FailedUpdatePassword = 0x0000_0001, @@ -234,6 +244,16 @@ pub enum LogonErrorNotificationDataErrorCode { Warning = 0x0000_0003, } +impl LogonErrorNotificationDataErrorCode { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum LogonErrorNotificationData { ErrorCode(LogonErrorNotificationDataErrorCode), @@ -243,7 +263,7 @@ pub enum LogonErrorNotificationData { impl LogonErrorNotificationData { pub fn to_u32(&self) -> u32 { match self { - LogonErrorNotificationData::ErrorCode(code) => code.to_u32().unwrap(), + LogonErrorNotificationData::ErrorCode(code) => code.as_u32(), LogonErrorNotificationData::SessionId(id) => *id, } } diff --git a/crates/ironrdp-pdu/src/rdp/suppress_output.rs b/crates/ironrdp-pdu/src/rdp/suppress_output.rs index 86f543368b..684e7393e6 100644 --- a/crates/ironrdp-pdu/src/rdp/suppress_output.rs +++ b/crates/ironrdp-pdu/src/rdp/suppress_output.rs @@ -6,7 +6,7 @@ use ironrdp_core::{ use crate::geometry::InclusiveRectangle; #[repr(u8)] -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Copy, Clone)] pub enum AllowDisplayUpdatesType { SuppressDisplayUpdates = 0x00, AllowDisplayUpdates = 0x01, @@ -21,6 +21,10 @@ impl AllowDisplayUpdatesType { } } + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] pub fn as_u8(self) -> u8 { self as u8 } diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx.rs index 90afd4a7f5..8b5833dd2c 100644 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx.rs +++ b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx.rs @@ -13,8 +13,8 @@ use ironrdp_core::{ cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ServerPdu { @@ -52,7 +52,7 @@ impl Encode for ServerPdu { let buffer_length = self.size(); - dst.write_u16(ServerPduType::from(self).to_u16().unwrap()); + dst.write_u16(ServerPduType::from(self).as_u16()); dst.write_u16(0); // flags dst.write_u32(cast_length!("bufferLen", buffer_length)?); @@ -175,7 +175,7 @@ impl Encode for ClientPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - dst.write_u16(ClientPduType::from(self).to_u16().unwrap()); + dst.write_u16(ClientPduType::from(self).as_u16()); dst.write_u16(0); // flags dst.write_u32(cast_length!("bufferLen", self.size())?); @@ -221,7 +221,8 @@ impl<'a> Decode<'a> for ClientPdu { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ClientPduType { FrameAcknowledge = 0x0d, CacheImportOffer = 0x10, @@ -229,6 +230,16 @@ pub enum ClientPduType { QoeFrameAcknowledge = 0x16, } +impl ClientPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl<'a> From<&'a ClientPdu> for ClientPduType { fn from(c: &'a ClientPdu) -> Self { match c { @@ -238,7 +249,8 @@ impl<'a> From<&'a ClientPdu> for ClientPduType { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum ServerPduType { WireToSurface1 = 0x01, WireToSurface2 = 0x02, @@ -261,6 +273,16 @@ pub enum ServerPduType { MapSurfaceToScaledWindow = 0x18, } +impl ServerPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl<'a> From<&'a ServerPdu> for ServerPduType { fn from(s: &'a ServerPdu) -> Self { match s { diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages.rs index 6f341f77c1..91745efb91 100644 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages.rs +++ b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages.rs @@ -3,8 +3,8 @@ mod server; mod avc_messages; use bitflags::bitflags; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; #[rustfmt::skip] // do not re-order this pub use avc_messages::{Avc420BitmapStream, Avc444BitmapStream, Encoding, QuantQuality}; @@ -71,7 +71,7 @@ impl Encode for CapabilitySet { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - dst.write_u32(self.version().to_u32().unwrap()); + dst.write_u32(self.version().as_u32()); dst.write_u32(cast_length!("dataLength", self.size() - CAPABILITY_SET_HEADER_SIZE)?); match self { @@ -275,7 +275,7 @@ impl<'de> Decode<'de> for Point { } #[repr(u32)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub(crate) enum CapabilityVersion { V8 = 0x8_0004, V8_1 = 0x8_0105, @@ -291,6 +291,16 @@ pub(crate) enum CapabilityVersion { Unknown = 0xa_0702, } +impl CapabilityVersion { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilitiesV8Flags: u32 { diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs index 8e366ff103..e6228ba75b 100644 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs +++ b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs @@ -5,8 +5,8 @@ use ironrdp_core::{ cast_length, decode_cursor, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use super::{CapabilitySet, Color, Point, RDP_GFX_HEADER_SIZE}; use crate::gcc::Monitor; @@ -49,8 +49,8 @@ impl Encode for WireToSurface1Pdu { ensure_size!(in: dst, size: self.size()); dst.write_u16(self.surface_id); - dst.write_u16(self.codec_id.to_u16().unwrap()); - dst.write_u8(self.pixel_format.to_u8().unwrap()); + dst.write_u16(self.codec_id.as_u16()); + dst.write_u8(self.pixel_format.as_u8()); self.destination_rectangle.encode(dst)?; dst.write_u32(cast_length!("BitmapDataLen", self.bitmap_data.len())?); dst.write_slice(&self.bitmap_data); @@ -123,9 +123,9 @@ impl Encode for WireToSurface2Pdu { ensure_size!(in: dst, size: self.size()); dst.write_u16(self.surface_id); - dst.write_u16(self.codec_id.to_u16().unwrap()); + dst.write_u16(self.codec_id.as_u16()); dst.write_u32(self.codec_context_id); - dst.write_u8(self.pixel_format.to_u8().unwrap()); + dst.write_u8(self.pixel_format.as_u8()); dst.write_u32(cast_length!("BitmapDataLen", self.bitmap_data.len())?); dst.write_slice(&self.bitmap_data); @@ -460,7 +460,7 @@ impl Encode for CreateSurfacePdu { dst.write_u16(self.surface_id); dst.write_u16(self.width); dst.write_u16(self.height); - dst.write_u8(self.pixel_format.to_u8().unwrap()); + dst.write_u8(self.pixel_format.as_u8()); Ok(()) } @@ -931,7 +931,7 @@ impl<'a> Decode<'a> for CapabilitiesConfirmPdu { } #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum Codec1Type { Uncompressed = 0x0, RemoteFx = 0x3, @@ -943,19 +943,45 @@ pub enum Codec1Type { Avc444v2 = 0xf, } +impl Codec1Type { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum Codec2Type { RemoteFxProgressive = 0x9, } +impl Codec2Type { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u8)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum PixelFormat { XRgb = 0x20, ARgb = 0x21, } +impl PixelFormat { + fn as_u8(self) -> u8 { + self as u8 + } +} + #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct Timestamp { pub milliseconds: u16, diff --git a/crates/ironrdp-pdu/src/utils.rs b/crates/ironrdp-pdu/src/utils.rs index fca9288e5b..4326ace3ba 100644 --- a/crates/ironrdp-pdu/src/utils.rs +++ b/crates/ironrdp-pdu/src/utils.rs @@ -3,17 +3,16 @@ use core::ops::Add; use byteorder::{LittleEndian, ReadBytesExt as _}; use ironrdp_core::{ensure_size, invalid_field_err, other_err, ReadCursor, WriteCursor}; -use num_derive::{FromPrimitive, ToPrimitive}; +use num_derive::FromPrimitive; use crate::{DecodeResult, EncodeResult}; pub fn split_u64(value: u64) -> (u32, u32) { - let bytes = value.to_le_bytes(); - let (low, high) = bytes.split_at(size_of::()); - ( - u32::from_le_bytes(low.try_into().unwrap()), - u32::from_le_bytes(high.try_into().unwrap()), - ) + let low = + u32::try_from(value & 0xFFFF_FFFF).expect("masking with 0xFFFF_FFFF ensures that the value fits into u32"); + let high = u32::try_from(value >> 32).expect("(u64 >> 32) fits into u32"); + + (low, high) } pub fn combine_u64(lo: u32, hi: u32) -> u64 { @@ -39,12 +38,23 @@ pub fn from_utf16_bytes(mut value: &[u8]) -> String { String::from_utf16_lossy(value_u16.as_ref()) } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum CharacterSet { Ansi = 1, Unicode = 2, } +impl CharacterSet { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u16(self) -> u16 { + self as u16 + } +} + // Read a string from the cursor, using the specified character set. // // If read_null_terminator is true, the string will be read until a null terminator is found. diff --git a/crates/ironrdp-pdu/src/x224.rs b/crates/ironrdp-pdu/src/x224.rs index 1c84dcab36..a6da76d4ea 100644 --- a/crates/ironrdp-pdu/src/x224.rs +++ b/crates/ironrdp-pdu/src/x224.rs @@ -1,7 +1,8 @@ use std::borrow::Cow; use ironrdp_core::{ - ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, WriteCursor, + cast_length, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, + WriteCursor, }; use crate::tpdu::{TpduCode, TpduHeader}; @@ -42,16 +43,16 @@ where ensure_size!(in: dst, size: packet_length); TpktHeader { - packet_length: u16::try_from(packet_length).unwrap(), + packet_length: cast_length!("packet length", packet_length)?, } .write(dst)?; - TpduHeader { - li: u8::try_from(T::TPDU_CODE.header_fixed_part_size() + self.0.tpdu_header_variable_part_size() - 1) - .unwrap(), - code: T::TPDU_CODE, - } - .write(dst)?; + let li = cast_length!( + "length indicator", + (T::TPDU_CODE.header_fixed_part_size() + self.0.tpdu_header_variable_part_size() - 1) + )?; + + TpduHeader { li, code: T::TPDU_CODE }.write(dst)?; self.0.x224_body_encode(dst) } diff --git a/crates/ironrdp-testsuite-core/src/conference_create.rs b/crates/ironrdp-testsuite-core/src/conference_create.rs index 8b3763ec81..d90398fba4 100644 --- a/crates/ironrdp-testsuite-core/src/conference_create.rs +++ b/crates/ironrdp-testsuite-core/src/conference_create.rs @@ -15,13 +15,11 @@ pub const CONFERENCE_CREATE_RESPONSE_PREFIX_BUFFER: [u8; 24] = [ ]; lazy_static! { - pub static ref CONFERENCE_CREATE_REQUEST: ConferenceCreateRequest = ConferenceCreateRequest { - gcc_blocks: gcc::CLIENT_GCC_WITH_CLUSTER_OPTIONAL_FIELD.clone(), - }; - pub static ref CONFERENCE_CREATE_RESPONSE: ConferenceCreateResponse = ConferenceCreateResponse { - user_id: 0x79f3, - gcc_blocks: gcc::SERVER_GCC_WITHOUT_OPTIONAL_FIELDS.clone(), - }; + pub static ref CONFERENCE_CREATE_REQUEST: ConferenceCreateRequest = + ConferenceCreateRequest::new(gcc::CLIENT_GCC_WITH_CLUSTER_OPTIONAL_FIELD.clone()).expect("should not fail"); + pub static ref CONFERENCE_CREATE_RESPONSE: ConferenceCreateResponse = + ConferenceCreateResponse::new(0x79f3, gcc::SERVER_GCC_WITHOUT_OPTIONAL_FIELDS.clone(),) + .expect("should not fail"); } pub const CONFERENCE_CREATE_REQUEST_BUFFER: [u8; concat_arrays_size!( From fd43129b138c4be8e7f3e23b95c814539835482a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 23:46:33 +0000 Subject: [PATCH 019/325] build(deps): bump the patch group across 2 directories with 4 updates (#968) --- Cargo.lock | 74 ++++++++++++++++++++++++------------------------- fuzz/Cargo.lock | 4 +-- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9be922cd7d..a2bb8bc9a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,12 +148,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -666,16 +660,15 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -2962,9 +2955,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" dependencies = [ "once_cell", "wasm-bindgen", @@ -5946,21 +5939,22 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" dependencies = [ "bumpalo", "log", @@ -5972,9 +5966,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" dependencies = [ "cfg-if", "js-sys", @@ -5985,9 +5979,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5995,9 +5989,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" dependencies = [ "proc-macro2", "quote", @@ -6008,9 +6002,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" dependencies = [ "unicode-ident", ] @@ -6126,9 +6120,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" dependencies = [ "js-sys", "wasm-bindgen", @@ -6232,7 +6226,7 @@ dependencies = [ "windows-collections", "windows-core 0.61.2", "windows-future", - "windows-link", + "windows-link 0.1.3", "windows-numerics", ] @@ -6263,7 +6257,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings", ] @@ -6275,7 +6269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", - "windows-link", + "windows-link 0.1.3", "windows-threading", ] @@ -6307,6 +6301,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-numerics" version = "0.2.0" @@ -6314,7 +6314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core 0.61.2", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -6323,7 +6323,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings", ] @@ -6343,7 +6343,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -6352,7 +6352,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -6452,7 +6452,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -6469,7 +6469,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -6892,9 +6892,9 @@ dependencies = [ [[package]] name = "yuv" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b08262a503468e0123115a872ac2fd250f965e0178489d393686e9dd19b47e6" +checksum = "c3bb136c6b36d2856e62f3121892ae59f32caf5b0a9ff184600f0f5f4d5ff075" dependencies = [ "num-traits", ] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 533f72e858..6eea8e3a1b 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -820,9 +820,9 @@ dependencies = [ [[package]] name = "yuv" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b08262a503468e0123115a872ac2fd250f965e0178489d393686e9dd19b47e6" +checksum = "c3bb136c6b36d2856e62f3121892ae59f32caf5b0a9ff184600f0f5f4d5ff075" dependencies = [ "num-traits", ] From 2259bd77061769dc0be10cbc05b6924b43d96a8c Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:22:29 +0300 Subject: [PATCH 020/325] refactor: add `string_slice` clippy correctness lint (#970) This lint checks for slice operations on strings. From `string_slice` [docs](https://rust-lang.github.io/rust-clippy/master/index.html#/string_slice): > UTF-8 characters span multiple bytes, and it is easy to inadvertently confuse character counts and string indices. This may lead to panics, and should warrant some test cases containing wide UTF-8 characters. This lint is most useful in code that should avoid panics at all costs. --- Cargo.toml | 1 + crates/ironrdp-client/src/config.rs | 6 +++--- crates/ironrdp-connector/src/server_name.rs | 4 ++-- crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs | 5 +---- crates/ironrdp-rdpdr/src/pdu/efs.rs | 4 +++- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 04e26877dd..d09d891890 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ panic = "warn" precedence_bits = "warn" rc_mutex = "warn" same_name_method = "warn" +string_slice = "warn" # == Style, readability == # semicolon_outside_block = "warn" # With semicolon-outside-block-ignore-multiline = true diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index a24d7ccac1..3ebd37222d 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -88,7 +88,7 @@ impl Destination { let addr = addr.into(); - if let Some(idx) = addr.rfind(':') { + if let Some(addr_split) = addr.rsplit_once(':') { if let Ok(sock_addr) = addr.parse::() { Ok(Self { name: sock_addr.ip().to_string(), @@ -101,8 +101,8 @@ impl Destination { }) } else { Ok(Self { - name: addr[..idx].to_owned(), - port: addr[idx + 1..].parse().context("invalid port")?, + name: addr_split.0.to_owned(), + port: addr_split.1.parse().context("invalid port")?, }) } } else { diff --git a/crates/ironrdp-connector/src/server_name.rs b/crates/ironrdp-connector/src/server_name.rs index 4f5854ebbe..f864db8b22 100644 --- a/crates/ironrdp-connector/src/server_name.rs +++ b/crates/ironrdp-connector/src/server_name.rs @@ -34,7 +34,7 @@ impl From<&str> for ServerName { } fn sanitize_server_name(name: String) -> String { - if let Some(idx) = name.rfind(':') { + if let Some(addr_split) = name.rsplit_once(':') { if let Ok(sock_addr) = name.parse::() { // A socket address, including a port sock_addr.ip().to_string() @@ -43,7 +43,7 @@ fn sanitize_server_name(name: String) -> String { name } else { // An IPv4 address or server hostname including a port after the `:` token - name[..idx].to_owned() + addr_split.0.to_owned() } } else { // An IPv4 address or server hostname which does not include a port, already sane diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs index 6c31a3adbe..cb42178ffa 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs @@ -695,10 +695,7 @@ fn parse_codecs_config<'a>(codecs: &'a [&'a str]) -> Result true, "off" => false, diff --git a/crates/ironrdp-rdpdr/src/pdu/efs.rs b/crates/ironrdp-rdpdr/src/pdu/efs.rs index 5edd7f33f5..f324cf1633 100644 --- a/crates/ironrdp-rdpdr/src/pdu/efs.rs +++ b/crates/ironrdp-rdpdr/src/pdu/efs.rs @@ -966,7 +966,9 @@ impl PreferredDosName { fn format(&self) -> String { let mut name: &str = &self.0; if name.len() > 7 { - name = &name[..7]; + name = name + .get(..7) + .expect("index is guaranteed to be on a UTF-8 boundary for a string of ASCII characters"); } format!("{name:\x00<8}") } From 5ddf68de790ebc1d865e1956b80e1cbe1c450912 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 9 Sep 2025 13:07:35 +0000 Subject: [PATCH 021/325] fix(cliprdr): add missing std feature to ironrdp-code dependency (#971) --- crates/ironrdp-cliprdr-format/Cargo.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ironrdp-cliprdr-format/Cargo.toml b/crates/ironrdp-cliprdr-format/Cargo.toml index 947c3039a1..e5d4c4fbdf 100644 --- a/crates/ironrdp-cliprdr-format/Cargo.toml +++ b/crates/ironrdp-cliprdr-format/Cargo.toml @@ -16,9 +16,8 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["std"] } # public png = "0.18" [lints] workspace = true - From 8a8027481f4a03eef5e1de16b2b5051e78c80e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 10 Sep 2025 02:05:09 +0900 Subject: [PATCH 022/325] build: optimize binary size for FFI builds Still optimizing for performance, but enabled the following options: > strip = "symbols" > codegen-units = 1 > lto = true - Baseline: 8.9M - New: 5.6M --- .github/workflows/nuget-publish.yml | 4 ++-- Cargo.toml | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index e76f4e565c..267a0fe3c7 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -198,7 +198,7 @@ jobs: $CargoParams = @( "build", "-p", "ffi", - "--release", + "--profile", "production-ffi", "--target", "$RustTarget" ) @@ -206,7 +206,7 @@ jobs: $OutputLibraryName = "${LibPrefix}ironrdp$LibSuffix" $RenamedLibraryName = "${LibPrefix}DevolutionsIronRdp$LibSuffix" - $OutputLibrary = Join-Path "target" $RustTarget 'release' $OutputLibraryName + $OutputLibrary = Join-Path "target" $RustTarget 'production-ffi' $OutputLibraryName $OutputPath = Join-Path "dependencies" "runtimes" $DotNetRid "native" New-Item -ItemType Directory -Path $OutputPath | Out-Null Copy-Item $OutputLibrary $(Join-Path $OutputPath $RenamedLibraryName) diff --git a/Cargo.toml b/Cargo.toml index d09d891890..e8accc5af9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -175,6 +175,12 @@ opt-level = 1 inherits = "release" lto = true +[profile.production-ffi] +inherits = "release" +strip = "symbols" +codegen-units = 1 +lto = true + [profile.production-wasm] inherits = "release" opt-level = "s" From bd8f2743d6104c849adc5a76016ad3f9d30d60f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 10 Sep 2025 02:06:12 +0900 Subject: [PATCH 023/325] chore(release): prepare for Devolutions.IronRdp v2025.10.9.0 --- ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj index bd99928c2c..00ba0ce5d8 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj +++ b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj @@ -4,7 +4,7 @@ Devolutions Bindings to Rust IronRDP native library latest - 2024.5.22.0 + 2024.10.9.0 enable enable true From 898df8c0fa3f66432af843e7b973cd9e4e3cb3f6 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Wed, 10 Sep 2025 15:47:12 +0300 Subject: [PATCH 024/325] chore: add `unused_result_ok` clippy correctness lint (#974) This lint checks for calls to `Result::ok()` without using the returned `Option`. --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index e8accc5af9..cf0809a194 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,7 @@ precedence_bits = "warn" rc_mutex = "warn" same_name_method = "warn" string_slice = "warn" +unused_result_ok = "warn" # == Style, readability == # semicolon_outside_block = "warn" # With semicolon-outside-block-ignore-multiline = true From a8b6fb1d743799bba3f124b465279acf39bc54d9 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:04:12 +0300 Subject: [PATCH 025/325] chore: add `suspicious_xor_user_as_pow` clippy correctness lint (#973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the lint [docs](https://rust-lang.github.io/rust-clippy/stable/index.html#suspicious_xor_used_as_pow): > Warns for a Bitwise XOR (^) operator being probably confused as a powering. It will not trigger if any of the numbers are not in decimal. It’s most probably a typo and may lead to unexpected behaviours. --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index cf0809a194..8d35092b63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,7 @@ precedence_bits = "warn" rc_mutex = "warn" same_name_method = "warn" string_slice = "warn" +suspicious_xor_used_as_pow = "warn" unused_result_ok = "warn" # == Style, readability == # From 630525deae92f39bfed53248ab0fec0e71249322 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:41:02 +0300 Subject: [PATCH 026/325] refactor!: enable `unwrap_used` clippy correctness lint (#965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Benoît CORTIER --- Cargo.toml | 2 +- benches/src/perfenc.rs | 28 ++++--- clippy.toml | 1 + crates/ironrdp-acceptor/src/connection.rs | 2 +- crates/ironrdp-ainput/src/lib.rs | 34 ++++++-- crates/ironrdp-bench/benches/bench.rs | 40 +++++++-- crates/ironrdp-blocking/src/connector.rs | 4 +- crates/ironrdp-client/src/app.rs | 30 ++++--- crates/ironrdp-client/src/config.rs | 5 +- crates/ironrdp-client/src/main.rs | 4 +- crates/ironrdp-client/src/rdp.rs | 29 +++++-- crates/ironrdp-cliprdr-format/src/bitmap.rs | 6 +- .../ironrdp-cliprdr/src/pdu/file_contents.rs | 7 +- .../src/connection_activation.rs | 19 ++--- crates/ironrdp-displaycontrol/src/pdu/mod.rs | 19 +++-- crates/ironrdp-dvc/src/client.rs | 5 +- crates/ironrdp-dvc/src/lib.rs | 2 +- .../ironrdp-graphics/src/color_conversion.rs | 27 +++--- .../ironrdp-graphics/src/image_processing.rs | 19 ++++- crates/ironrdp-graphics/src/rdp6/rle.rs | 31 ++++--- crates/ironrdp-graphics/src/rlgr.rs | 63 ++++++++------ crates/ironrdp-graphics/src/zgfx/mod.rs | 7 +- crates/ironrdp-input/src/lib.rs | 5 +- crates/ironrdp-rdcleanpath/src/lib.rs | 28 ++++--- crates/ironrdp-rdpdr/src/pdu/efs.rs | 53 +----------- crates/ironrdp-rdpdr/src/pdu/esc.rs | 5 +- crates/ironrdp-rdpsnd-native/examples/cpal.rs | 2 +- crates/ironrdp-rdpsnd-native/src/cpal.rs | 27 ++++-- crates/ironrdp-rdpsnd/src/pdu/mod.rs | 2 +- crates/ironrdp-server/src/builder.rs | 12 +-- crates/ironrdp-server/src/display.rs | 6 +- crates/ironrdp-server/src/encoder/bitmap.rs | 71 +++++++++------- crates/ironrdp-server/src/encoder/mod.rs | 83 +++++++++++++------ crates/ironrdp-server/src/encoder/rfx.rs | 22 +++-- crates/ironrdp-server/src/server.rs | 51 +++++++----- crates/ironrdp-session/src/active_stage.rs | 3 +- crates/ironrdp-session/src/image.rs | 22 +++-- crates/ironrdp-session/src/rfx.rs | 6 +- crates/ironrdp-testsuite-core/src/lib.rs | 1 + .../tests/graphics/color_conversion.rs | 4 +- crates/ironrdp-testsuite-core/tests/main.rs | 1 + crates/ironrdp-testsuite-extra/tests/tests.rs | 5 +- crates/ironrdp-web/src/canvas.rs | 20 ++--- crates/ironrdp-web/src/session.rs | 30 ++++--- crates/ironrdp/examples/screenshot.rs | 7 +- crates/ironrdp/examples/server.rs | 28 ++++--- ffi/build.rs | 14 ++-- ffi/src/dvc/dvc_pipe_proxy_message_queue.rs | 5 +- xtask/src/main.rs | 3 +- 49 files changed, 534 insertions(+), 366 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8d35092b63..15b668e9e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ float_cmp = "warn" lossy_float_literal = "warn" float_cmp_const = "warn" as_underscore = "warn" -# TODO: unwrap_used = "warn" # Let’s either handle `None`, `Err` or use `expect` to give a reason. +unwrap_used = "warn" large_stack_frames = "warn" mem_forget = "warn" mixed_read_write_in_expression = "warn" diff --git a/benches/src/perfenc.rs b/benches/src/perfenc.rs index 45b1383fcd..a44821d8b2 100644 --- a/benches/src/perfenc.rs +++ b/benches/src/perfenc.rs @@ -2,7 +2,7 @@ #![allow(clippy::print_stderr)] #![allow(clippy::print_stdout)] -use core::num::NonZero; +use core::num::{NonZeroU16, NonZeroUsize}; use core::time::Duration; use std::io::Write as _; use std::time::Instant; @@ -59,13 +59,14 @@ async fn main() -> Result<(), anyhow::Error> { OptCodec::QoiZ => update_codecs.set_qoiz(Some(0)), }; - let mut encoder = UpdateEncoder::new(DesktopSize { width, height }, flags, update_codecs); + let mut encoder = UpdateEncoder::new(DesktopSize { width, height }, flags, update_codecs) + .context("failed to initialize update encoder")?; let mut total_raw = 0u64; let mut total_enc = 0u64; let mut n_updates = 0u64; let mut updates = DisplayUpdates::new(file, DesktopSize { width, height }, fps); - while let Some(up) = updates.next_update().await { + while let Some(up) = updates.next_update().await? { if let DisplayUpdate::Bitmap(ref up) = up { total_raw += up.data.len() as u64; } else { @@ -82,7 +83,7 @@ async fn main() -> Result<(), anyhow::Error> { } n_updates += 1; print!("."); - std::io::stdout().flush().unwrap(); + std::io::stdout().flush()?; } println!(); @@ -119,20 +120,21 @@ impl DisplayUpdates { #[async_trait::async_trait] impl RdpServerDisplayUpdates for DisplayUpdates { - async fn next_update(&mut self) -> Option { + async fn next_update(&mut self) -> anyhow::Result> { let stride = self.desktop_size.width as usize * 4; let frame_size = stride * self.desktop_size.height as usize; let mut buf = vec![0u8; frame_size]; - if self.file.read_exact(&mut buf).await.is_err() { - return None; - } + // FIXME: AsyncReadExt::read_exact is not cancellation safe. + self.file.read_exact(&mut buf).await.context("read exact")?; let now = Instant::now(); if let Some(last_update_time) = self.last_update_time { let elapsed = now - last_update_time; if self.fps > 0 && elapsed < Duration::from_millis(1000 / self.fps) { sleep(Duration::from_millis( - 1000 / self.fps - u64::try_from(elapsed.as_millis()).unwrap(), + 1000 / self.fps + - u64::try_from(elapsed.as_millis()) + .context("invalid `elapsed millis`: out of range integral conversion")?, )) .await; } @@ -142,13 +144,13 @@ impl RdpServerDisplayUpdates for DisplayUpdates { let up = DisplayUpdate::Bitmap(BitmapUpdate { x: 0, y: 0, - width: self.desktop_size.width.try_into().unwrap(), - height: self.desktop_size.height.try_into().unwrap(), + width: NonZeroU16::new(self.desktop_size.width).context("width cannot be zero")?, + height: NonZeroU16::new(self.desktop_size.height).context("height cannot be zero")?, format: PixelFormat::RgbX32, data: buf.into(), - stride: NonZero::new(stride).unwrap(), + stride: NonZeroUsize::new(stride).context("stride cannot be zero")?, }); - Some(up) + Ok(Some(up)) } } diff --git a/clippy.toml b/clippy.toml index 7fc7b2d0df..dd6a9fe4e9 100644 --- a/clippy.toml +++ b/clippy.toml @@ -3,3 +3,4 @@ semicolon-outside-block-ignore-multiline = true accept-comment-above-statement = true accept-comment-above-attributes = true allow-panic-in-tests = true +allow-unwrap-in-tests = true diff --git a/crates/ironrdp-acceptor/src/connection.rs b/crates/ironrdp-acceptor/src/connection.rs index f4355293e9..ca27245ceb 100644 --- a/crates/ironrdp-acceptor/src/connection.rs +++ b/crates/ironrdp-acceptor/src/connection.rs @@ -404,7 +404,7 @@ impl Sequence for Acceptor { .into_iter() .enumerate() .map(|(i, channel)| { - let channel_id = u16::try_from(i).unwrap() + self.io_channel_id + 1; + let channel_id = u16::try_from(i).expect("always in the range") + self.io_channel_id + 1; if let Some((type_id, c)) = channel { self.static_channels.attach_channel_id(type_id, channel_id); (channel_id, Some(c)) diff --git a/crates/ironrdp-ainput/src/lib.rs b/crates/ironrdp-ainput/src/lib.rs index c6cfdb9eb0..eba9c46986 100644 --- a/crates/ironrdp-ainput/src/lib.rs +++ b/crates/ironrdp-ainput/src/lib.rs @@ -6,8 +6,8 @@ use ironrdp_core::{ ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, }; use ironrdp_dvc::DvcEncode; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; // Advanced Input channel as defined from Freerdp, [here]: // // [here]: https://github.com/FreeRDP/FreeRDP/blob/master/include/freerdp/channels/ainput.h @@ -93,11 +93,22 @@ impl<'de> Decode<'de> for VersionPdu { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[repr(u16)] pub enum ServerPduType { Version = 0x01, } +impl ServerPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(&self) -> u16 { + *self as u16 + } +} + impl<'a> From<&'a ServerPdu> for ServerPduType { fn from(s: &'a ServerPdu) -> Self { match s { @@ -121,7 +132,7 @@ impl Encode for ServerPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(ServerPduType::from(self).to_u16().unwrap()); + dst.write_u16(ServerPduType::from(self).as_u16()); match self { ServerPdu::Version(pdu) => pdu.encode(dst), } @@ -220,7 +231,7 @@ impl Encode for ClientPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(ClientPduType::from(self).to_u16().unwrap()); + dst.write_u16(ClientPduType::from(self).as_u16()); match self { ClientPdu::Mouse(pdu) => pdu.encode(dst), } @@ -254,11 +265,22 @@ impl<'de> Decode<'de> for ClientPdu { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[repr(u16)] pub enum ClientPduType { Mouse = 0x02, } +impl ClientPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl<'a> From<&'a ClientPdu> for ClientPduType { fn from(s: &'a ClientPdu) -> Self { match s { diff --git a/crates/ironrdp-bench/benches/bench.rs b/crates/ironrdp-bench/benches/bench.rs index 1643430003..fed6123a0d 100644 --- a/crates/ironrdp-bench/benches/bench.rs +++ b/crates/ironrdp-bench/benches/bench.rs @@ -1,4 +1,4 @@ -use core::num::NonZero; +use core::num::{NonZeroU16, NonZeroUsize}; use criterion::{criterion_group, criterion_main, Criterion}; use ironrdp_graphics::color_conversion::to_64x64_ycbcr_tile; @@ -7,31 +7,42 @@ use ironrdp_server::bench::encoder::rfx::{rfx_enc, rfx_enc_tile}; use ironrdp_server::BitmapUpdate; pub fn rfx_enc_tile_bench(c: &mut Criterion) { + const WIDTH: NonZeroU16 = NonZeroU16::new(64).expect("value is guaranteed to be non-zero"); + const HEIGHT: NonZeroU16 = NonZeroU16::new(64).expect("value is guaranteed to be non-zero"); + const STRIDE: NonZeroUsize = NonZeroUsize::new(64 * 4).expect("value is guaranteed to be non-zero"); + let quant = rfx::Quant::default(); let algo = rfx::EntropyAlgorithm::Rlgr3; + let bitmap = BitmapUpdate { x: 0, y: 0, - width: NonZero::new(64).unwrap(), - height: NonZero::new(64).unwrap(), + width: WIDTH, + height: HEIGHT, format: ironrdp_server::PixelFormat::ARgb32, data: vec![0; 64 * 64 * 4].into(), - stride: NonZero::new(64 * 4).unwrap(), + stride: STRIDE, }; c.bench_function("rfx_enc_tile", |b| b.iter(|| rfx_enc_tile(&bitmap, &quant, algo, 0, 0))); } pub fn rfx_enc_bench(c: &mut Criterion) { + const WIDTH: NonZeroU16 = NonZeroU16::new(2048).expect("value is guaranteed to be non-zero"); + const HEIGHT: NonZeroU16 = NonZeroU16::new(2048).expect("value is guaranteed to be non-zero"); + // FIXME/QUESTION: It looks like we have a bug here, don't we? The stride value should be 2048 * 4. + const STRIDE: NonZeroUsize = NonZeroUsize::new(64 * 4).expect("value is guaranteed to be non-zero"); + let quant = rfx::Quant::default(); let algo = rfx::EntropyAlgorithm::Rlgr3; + let bitmap = BitmapUpdate { x: 0, y: 0, - width: NonZero::new(2048).unwrap(), - height: NonZero::new(2048).unwrap(), + width: WIDTH, + height: HEIGHT, format: ironrdp_server::PixelFormat::ARgb32, data: vec![0; 2048 * 2048 * 4].into(), - stride: NonZero::new(64 * 4).unwrap(), + stride: STRIDE, }; c.bench_function("rfx_enc", |b| b.iter(|| rfx_enc(&bitmap, &quant, algo))); } @@ -39,14 +50,27 @@ pub fn rfx_enc_bench(c: &mut Criterion) { pub fn to_ycbcr_bench(c: &mut Criterion) { const WIDTH: usize = 64; const HEIGHT: usize = 64; + let input = vec![0; WIDTH * HEIGHT * 4]; let stride = WIDTH * 4; let mut y = [0i16; WIDTH * HEIGHT]; let mut cb = [0i16; WIDTH * HEIGHT]; let mut cr = [0i16; WIDTH * HEIGHT]; let format = ironrdp_graphics::image_processing::PixelFormat::ARgb32; + c.bench_function("to_ycbcr", |b| { - b.iter(|| to_64x64_ycbcr_tile(&input, WIDTH, HEIGHT, stride, format, &mut y, &mut cb, &mut cr)) + b.iter(|| { + to_64x64_ycbcr_tile( + &input, + WIDTH.try_into().expect("can't panic"), + HEIGHT.try_into().expect("can't panic"), + stride.try_into().expect("can't panic"), + format, + &mut y, + &mut cb, + &mut cr, + ) + }) }); } diff --git a/crates/ironrdp-blocking/src/connector.rs b/crates/ironrdp-blocking/src/connector.rs index 80623e791f..9375b66ab7 100644 --- a/crates/ironrdp-blocking/src/connector.rs +++ b/crates/ironrdp-blocking/src/connector.rs @@ -100,7 +100,9 @@ fn resolve_generator( loop { match state { GeneratorState::Suspended(request) => { - let response = network_client.send(&request).unwrap(); + let response = network_client.send(&request).map_err(|e| { + ConnectorError::new("network client send", ironrdp_connector::ConnectorErrorKind::Credssp(e)) + })?; state = generator.resume(Ok(response)); } GeneratorState::Completed(client_state) => { diff --git a/crates/ironrdp-client/src/app.rs b/crates/ironrdp-client/src/app.rs index 81aca9960c..9f579c1781 100644 --- a/crates/ironrdp-client/src/app.rs +++ b/crates/ironrdp-client/src/app.rs @@ -5,9 +5,10 @@ use core::time::Duration; use std::sync::Arc; use std::time::Instant; +use anyhow::Context as _; use raw_window_handle::{DisplayHandle, HasDisplayHandle as _}; use tokio::sync::mpsc; -use tracing::{debug, error, trace}; +use tracing::{debug, error, trace, warn}; use winit::application::ApplicationHandler; use winit::dpi::{LogicalPosition, PhysicalSize}; use winit::event::{self, WindowEvent}; @@ -38,7 +39,9 @@ impl App { // SAFETY: We drop the softbuffer context right before the event loop is stopped, thus making this safe. // FIXME: This is not a sufficient proof and the API is actually unsound as-is. let display_handle = unsafe { - core::mem::transmute::, DisplayHandle<'static>>(event_loop.display_handle().unwrap()) + core::mem::transmute::, DisplayHandle<'static>>( + event_loop.display_handle().context("get display handle")?, + ) }; let context = softbuffer::Context::new(display_handle) .map_err(|e| anyhow::anyhow!("unable to initialize softbuffer context: {e}"))?; @@ -65,9 +68,12 @@ impl App { }; let scale_factor = (window.scale_factor() * 100.0) as u32; + let width = u16::try_from(size.width).expect("reasonable width"); + let height = u16::try_from(size.height).expect("reasonable height"); + let _ = self.input_event_sender.send(RdpInputEvent::Resize { - width: u16::try_from(size.width).unwrap(), - height: u16::try_from(size.height).unwrap(), + width, + height, scale_factor, // TODO: it should be possible to get the physical size here, however winit doesn't make it straightforward. // FreeRDP does it based on DPI reading grabbed via [`SDL_GetDisplayDPI`](https://wiki.libsdl.org/SDL2/SDL_GetDisplayDPI): @@ -160,7 +166,14 @@ impl ApplicationHandler for App { // } WindowEvent::KeyboardInput { event, .. } => { if let Some(scancode) = event.physical_key.to_scancode() { - let scancode = ironrdp::input::Scancode::from_u16(u16::try_from(scancode).unwrap()); + let scancode = match u16::try_from(scancode) { + Ok(scancode) => scancode, + Err(_) => { + warn!("Unsupported scancode: `{scancode:#X}`; ignored"); + return; + } + }; + let scancode = ironrdp::input::Scancode::from_u16(scancode); let operation = match event.state { event::ElementState::Pressed => ironrdp::input::Operation::KeyPressed(scancode), @@ -325,13 +338,10 @@ impl ApplicationHandler for App { RdpOutputEvent::Image { buffer, width, height } => { trace!(width = ?width, height = ?height, "Received image with size"); trace!(window_physical_size = ?window.inner_size(), "Drawing image to the window with size"); - self.buffer_size = (width, height); + self.buffer_size = (width.get(), height.get()); self.buffer = buffer; surface - .resize( - NonZeroU32::new(u32::from(width)).unwrap(), - NonZeroU32::new(u32::from(height)).unwrap(), - ) + .resize(NonZeroU32::from(width), NonZeroU32::from(height)) .expect("surface resize"); window.request_redraw(); diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 3ebd37222d..3477c1802f 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -439,10 +439,9 @@ impl Config { desktop_scale_factor: 0, // Default to 0 per FreeRDP bitmap: Some(bitmap), client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map(|version| version.major * 100 + version.minor * 10 + version.patch) - .unwrap_or(0) + .map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch) .pipe(u32::try_from) - .unwrap(), + .context("cargo package version")?, client_name: whoami::fallible::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), // NOTE: hardcode this value like in freerdp // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 diff --git a/crates/ironrdp-client/src/main.rs b/crates/ironrdp-client/src/main.rs index 15caaf99a1..18d41f3478 100644 --- a/crates/ironrdp-client/src/main.rs +++ b/crates/ironrdp-client/src/main.rs @@ -22,8 +22,8 @@ fn main() -> anyhow::Result<()> { // TODO: get window size & scale factor from GUI/App let window_size = (1024, 768); config.connector.desktop_scale_factor = 0; - config.connector.desktop_size.width = u16::try_from(window_size.0).unwrap(); - config.connector.desktop_size.height = u16::try_from(window_size.1).unwrap(); + config.connector.desktop_size.width = window_size.0; + config.connector.desktop_size.height = window_size.1; let rt = runtime::Builder::new_multi_thread() .enable_all() diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index c3f7341a2b..394050dd72 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -1,3 +1,4 @@ +use core::num::NonZeroU16; use std::sync::Arc; use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory}; @@ -30,11 +31,18 @@ use crate::config::{Config, RDCleanPathConfig}; #[derive(Debug)] pub enum RdpOutputEvent { - Image { buffer: Vec, width: u16, height: u16 }, + Image { + buffer: Vec, + width: NonZeroU16, + height: NonZeroU16, + }, ConnectionFailure(connector::ConnectorError), PointerDefault, PointerHidden, - PointerPosition { x: u16, y: u16 }, + PointerPosition { + x: u16, + y: u16, + }, PointerBitmap(Arc), Terminated(SessionResult), } @@ -516,14 +524,21 @@ async fn active_session( match input_event { RdpInputEvent::Resize { width, height, scale_factor, physical_size } => { trace!(width, height, "Resize event"); - let (width, height) = MonitorLayoutEntry::adjust_display_size(width.into(), height.into()); + let width = u32::from(width); + let height = u32::from(height); + // TODO: Make adjust_display_size take and return width and height as u16. + // From the function's doc comment, the width and height values must be less than or equal to 8192 pixels. + // Therefore, we can remove unnecessary casts from u16 to u32 and back. + let (width, height) = MonitorLayoutEntry::adjust_display_size(width, height); debug!(width, height, "Adjusted display size"); if let Some(response_frame) = active_stage.encode_resize(width, height, Some(scale_factor), physical_size) { vec![ActiveStageOutput::ResponseFrame(response_frame?)] } else { // TODO(#271): use the "auto-reconnect cookie": https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/15b0d1c9-2891-4adb-a45e-deb4aeeeab7c debug!("Reconnecting with new size"); - return Ok(RdpControlFlow::ReconnectWithNewSize { width: width.try_into().unwrap(), height: height.try_into().unwrap() }) + let width = u16::try_from(width).expect("always in the range"); + let height = u16::try_from(height).expect("always in the range"); + return Ok(RdpControlFlow::ReconnectWithNewSize { width, height }) } }, RdpInputEvent::FastPath(events) => { @@ -596,8 +611,10 @@ async fn active_session( event_loop_proxy .send_event(RdpOutputEvent::Image { buffer, - width: image.width(), - height: image.height(), + width: NonZeroU16::new(image.width()) + .ok_or_else(|| session::general_err!("width is zero"))?, + height: NonZeroU16::new(image.height()) + .ok_or_else(|| session::general_err!("height is zero"))?, }) .map_err(|e| session::custom_err!("event_loop_proxy", e))?; } diff --git a/crates/ironrdp-cliprdr-format/src/bitmap.rs b/crates/ironrdp-cliprdr-format/src/bitmap.rs index 5645d4ebbb..0dfcab860f 100644 --- a/crates/ironrdp-cliprdr-format/src/bitmap.rs +++ b/crates/ironrdp-cliprdr-format/src/bitmap.rs @@ -283,16 +283,14 @@ impl BitmapInfoHeader { fn width(&self) -> u16 { let abs = self.width.abs(); debug_assert!(abs <= 10_000); - // Per the invariant on self.width, this cast is infallible. - u16::try_from(abs).unwrap() + u16::try_from(abs).expect("per the invariant on self.width, this cast is infallible") } // INVARIANT: output (height) <= 10_000 fn height(&self) -> u16 { let abs = self.height.abs(); debug_assert!(abs <= 10_000); - // Per the invariant on self.height, this cast is infallible. - u16::try_from(abs).unwrap() + u16::try_from(abs).expect("per the invariant on self.height, this cast is infallible") } fn is_bottom_up(&self) -> bool { diff --git a/crates/ironrdp-cliprdr/src/pdu/file_contents.rs b/crates/ironrdp-cliprdr/src/pdu/file_contents.rs index daebe69000..0b329af978 100644 --- a/crates/ironrdp-cliprdr/src/pdu/file_contents.rs +++ b/crates/ironrdp-cliprdr/src/pdu/file_contents.rs @@ -101,7 +101,12 @@ impl<'a> FileContentsResponse<'a> { )); } - Ok(u64::from_le_bytes(self.data.as_ref().try_into().unwrap())) + Ok(u64::from_le_bytes( + self.data + .as_ref() + .try_into() + .expect("data contains exactly eight u8 elements"), + )) } } diff --git a/crates/ironrdp-connector/src/connection_activation.rs b/crates/ironrdp-connector/src/connection_activation.rs index d70b5ddf76..d268f9672a 100644 --- a/crates/ironrdp-connector/src/connection_activation.rs +++ b/crates/ironrdp-connector/src/connection_activation.rs @@ -155,7 +155,7 @@ impl Sequence for ConnectionActivationSequence { }); let client_confirm_active = rdp::headers::ShareControlPdu::ClientConfirmActive( - create_client_confirm_active(&self.config, capability_sets, desktop_size), + create_client_confirm_active(&self.config, capability_sets, desktop_size)?, ); debug!(message = ?client_confirm_active, "Send"); @@ -263,7 +263,7 @@ fn create_client_confirm_active( config: &Config, mut server_capability_sets: Vec, desktop_size: DesktopSize, -) -> rdp::capability_sets::ClientConfirmActive { +) -> ConnectorResult { use ironrdp_pdu::rdp::capability_sets::{ client_codecs_capabilities, Bitmap, BitmapCache, BitmapDrawingFlags, Brush, CacheDefinition, CacheEntry, ClientConfirmActive, CmdFlags, DemandActive, FrameAcknowledge, General, GeneralExtraFlags, GlyphCache, @@ -365,13 +365,10 @@ fn create_client_confirm_active( CapabilitySet::SurfaceCommands(SurfaceCommands { flags: CmdFlags::SET_SURFACE_BITS | CmdFlags::STREAM_SURFACE_BITS | CmdFlags::FRAME_MARKER, }), - CapabilitySet::BitmapCodecs( - config - .bitmap - .as_ref() - .map(|b| b.codecs.clone()) - .unwrap_or_else(|| client_codecs_capabilities(&[]).unwrap()), - ), + CapabilitySet::BitmapCodecs(match config.bitmap.as_ref().map(|b| b.codecs.clone()) { + Some(codecs) => codecs, + None => client_codecs_capabilities(&[]).expect("can't panic for &[]"), + }), CapabilitySet::FrameAcknowledge(FrameAcknowledge { // FIXME(#447): Revert this to 2 per FreeRDP. // This is a temporary hack to fix a resize bug, see: @@ -389,11 +386,11 @@ fn create_client_confirm_active( })); } - ClientConfirmActive { + Ok(ClientConfirmActive { originator_id: SERVER_CHANNEL_ID, pdu: DemandActive { source_descriptor: "IRONRDP".to_owned(), capability_sets: server_capability_sets, }, - } + }) } diff --git a/crates/ironrdp-displaycontrol/src/pdu/mod.rs b/crates/ironrdp-displaycontrol/src/pdu/mod.rs index 6d7fab0144..d89c632f90 100644 --- a/crates/ironrdp-displaycontrol/src/pdu/mod.rs +++ b/crates/ironrdp-displaycontrol/src/pdu/mod.rs @@ -3,7 +3,8 @@ //! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpedisp/d2954508-f487-48bc-8731-39743e0854a9 use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + cast_length, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, + WriteCursor, }; use ironrdp_dvc::DvcEncode; use tracing::warn; @@ -45,11 +46,11 @@ impl Encode for DisplayControlPdu { // This will never overflow as per invariants. #[expect(clippy::arithmetic_side_effects)] - let pdu_size = payload_length + Self::FIXED_PART_SIZE; + let pdu_size = cast_length!("pdu size", payload_length + Self::FIXED_PART_SIZE)?; // Write `DISPLAYCONTROL_HEADER` fields. dst.write_u32(kind); - dst.write_u32(pdu_size.try_into().unwrap()); + dst.write_u32(pdu_size); match self { DisplayControlPdu::Caps(caps) => caps.encode(dst), @@ -87,7 +88,7 @@ impl<'de> Decode<'de> for DisplayControlPdu { let pdu_length = src.read_u32(); let _payload_length = pdu_length - .checked_sub(Self::FIXED_PART_SIZE.try_into().unwrap()) + .checked_sub(Self::FIXED_PART_SIZE.try_into().expect("always in range")) .ok_or_else(|| invalid_field_err!("Length", "Display control PDU length is too small"))?; match kind { @@ -274,7 +275,7 @@ impl DisplayControlMonitorLayout { entry }; - Ok(DisplayControlMonitorLayout::new(&[entry]).unwrap()) + DisplayControlMonitorLayout::new(&[entry]) } pub fn monitors(&self) -> &[MonitorLayoutEntry] { @@ -286,7 +287,7 @@ impl Encode for DisplayControlMonitorLayout { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(MonitorLayoutEntry::FIXED_PART_SIZE.try_into().unwrap()); + dst.write_u32(MonitorLayoutEntry::FIXED_PART_SIZE.try_into().expect("always in range")); let monitors_count: u32 = self .monitors @@ -323,20 +324,20 @@ impl<'de> Decode<'de> for DisplayControlMonitorLayout { let monitor_layout_size = src.read_u32(); - if monitor_layout_size != MonitorLayoutEntry::FIXED_PART_SIZE.try_into().unwrap() { + if monitor_layout_size != MonitorLayoutEntry::FIXED_PART_SIZE.try_into().expect("always in range") { return Err(invalid_field_err!( "MonitorLayoutSize", "Monitor layout size is invalid" )); } - let num_monitors = src.read_u32(); + let num_monitors = cast_length!("number of monitors", src.read_u32())?; if num_monitors > MAX_SUPPORTED_MONITORS.into() { return Err(invalid_field_err!("NumMonitors", "Too many monitors")); } - let mut monitors = Vec::with_capacity(usize::try_from(num_monitors).unwrap()); + let mut monitors = Vec::with_capacity(num_monitors); for _ in 0..num_monitors { let monitor = MonitorLayoutEntry::decode(src)?; monitors.push(monitor); diff --git a/crates/ironrdp-dvc/src/client.rs b/crates/ironrdp-dvc/src/client.rs index 25cf7eeaf8..532b100019 100644 --- a/crates/ironrdp-dvc/src/client.rs +++ b/crates/ironrdp-dvc/src/client.rs @@ -133,7 +133,10 @@ impl SvcProcessor for DrdynvcClient { // and get any start messages. self.dynamic_channels .attach_channel_id(channel_name.clone(), channel_id); - let dynamic_channel = self.dynamic_channels.get_by_channel_name_mut(&channel_name).unwrap(); + let dynamic_channel = self + .dynamic_channels + .get_by_channel_name_mut(&channel_name) + .expect("channel exists"); (CreationStatus::OK, dynamic_channel.start()?) } else { (CreationStatus::NO_LISTENER, Vec::new()) diff --git a/crates/ironrdp-dvc/src/lib.rs b/crates/ironrdp-dvc/src/lib.rs index bb2a9f6c2e..48294324b6 100644 --- a/crates/ironrdp-dvc/src/lib.rs +++ b/crates/ironrdp-dvc/src/lib.rs @@ -73,7 +73,7 @@ pub fn encode_dvc_messages( while off < total_length { let first = off == 0; - let remaining_length = total_length.checked_sub(off).unwrap(); + let remaining_length = total_length.checked_sub(off).expect("never overflow"); let size = core::cmp::min(remaining_length, DrdynvcDataPdu::MAX_DATA_SIZE); let end = off .checked_add(size) diff --git a/crates/ironrdp-graphics/src/color_conversion.rs b/crates/ironrdp-graphics/src/color_conversion.rs index 7ce4c82956..46f78ad67c 100644 --- a/crates/ironrdp-graphics/src/color_conversion.rs +++ b/crates/ironrdp-graphics/src/color_conversion.rs @@ -2,7 +2,7 @@ use std::io; use yuv::{ rdp_abgr_to_yuv444, rdp_argb_to_yuv444, rdp_bgra_to_yuv444, rdp_rgba_to_yuv444, rdp_yuv444_to_argb, - rdp_yuv444_to_rgba, BufferStoreMut, YuvPlanarImage, YuvPlanarImageMut, + rdp_yuv444_to_rgba, BufferStoreMut, YuvError, YuvPlanarImage, YuvPlanarImageMut, }; use crate::image_processing::PixelFormat; @@ -43,14 +43,14 @@ pub fn ycbcr_to_rgba(input: YCbCrBuffer<'_>, output: &mut [u8]) -> io::Result<() #[expect(clippy::too_many_arguments)] pub fn to_64x64_ycbcr_tile( input: &[u8], - width: usize, - height: usize, - stride: usize, + width: u32, + height: u32, + stride: u32, format: PixelFormat, y: &mut [i16; 64 * 64], cb: &mut [i16; 64 * 64], cr: &mut [i16; 64 * 64], -) { +) -> Result<(), YuvError> { assert!(width <= 64); assert!(height <= 64); @@ -64,17 +64,16 @@ pub fn to_64x64_ycbcr_tile( u_stride: 64, v_plane, v_stride: 64, - width: width.try_into().unwrap(), - height: height.try_into().unwrap(), + width, + height, }; - let res = match format { - PixelFormat::RgbA32 | PixelFormat::RgbX32 => rdp_rgba_to_yuv444(&mut plane, input, stride.try_into().unwrap()), - PixelFormat::ARgb32 | PixelFormat::XRgb32 => rdp_argb_to_yuv444(&mut plane, input, stride.try_into().unwrap()), - PixelFormat::BgrA32 | PixelFormat::BgrX32 => rdp_bgra_to_yuv444(&mut plane, input, stride.try_into().unwrap()), - PixelFormat::ABgr32 | PixelFormat::XBgr32 => rdp_abgr_to_yuv444(&mut plane, input, stride.try_into().unwrap()), - }; - res.unwrap(); + match format { + PixelFormat::RgbA32 | PixelFormat::RgbX32 => rdp_rgba_to_yuv444(&mut plane, input, stride), + PixelFormat::ARgb32 | PixelFormat::XRgb32 => rdp_argb_to_yuv444(&mut plane, input, stride), + PixelFormat::BgrA32 | PixelFormat::BgrX32 => rdp_bgra_to_yuv444(&mut plane, input, stride), + PixelFormat::ABgr32 | PixelFormat::XBgr32 => rdp_abgr_to_yuv444(&mut plane, input, stride), + } } /// Convert a 16-bit RDP color to RGB representation. Input value should be represented in diff --git a/crates/ironrdp-graphics/src/image_processing.rs b/crates/ironrdp-graphics/src/image_processing.rs index 1dc22bbf8b..501424eeb7 100644 --- a/crates/ironrdp-graphics/src/image_processing.rs +++ b/crates/ironrdp-graphics/src/image_processing.rs @@ -3,8 +3,6 @@ use std::io; use byteorder::WriteBytesExt as _; use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _}; -use num_derive::ToPrimitive; -use num_traits::ToPrimitive as _; const ALPHA_OPAQUE: u8 = 0xff; @@ -99,7 +97,7 @@ impl ImageRegion<'_> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum PixelFormat { ARgb32 = 536_971_400, XRgb32 = 536_938_632, @@ -130,6 +128,19 @@ impl TryFrom for PixelFormat { } impl PixelFormat { + fn as_u32(&self) -> u32 { + match self { + Self::ARgb32 => 536_971_400, + Self::XRgb32 => 536_938_632, + Self::ABgr32 => 537_036_936, + Self::XBgr32 => 537_004_168, + Self::BgrA32 => 537_168_008, + Self::BgrX32 => 537_135_240, + Self::RgbA32 => 537_102_472, + Self::RgbX32 => 537_069_704, + } + } + pub const fn bytes_per_pixel(self) -> u8 { match self { Self::ARgb32 @@ -146,7 +157,7 @@ impl PixelFormat { pub fn eq_no_alpha(self, other: Self) -> bool { let mask = !(8 << 12); - (self.to_u32().unwrap() & mask) == (other.to_u32().unwrap() & mask) + (self.as_u32() & mask) == (other.as_u32() & mask) } pub fn read_color(self, buffer: &[u8]) -> io::Result { diff --git a/crates/ironrdp-graphics/src/rdp6/rle.rs b/crates/ironrdp-graphics/src/rdp6/rle.rs index e8540f21ea..7f74cb572d 100644 --- a/crates/ironrdp-graphics/src/rdp6/rle.rs +++ b/crates/ironrdp-graphics/src/rdp6/rle.rs @@ -249,8 +249,8 @@ macro_rules! ensure_size { (dst: $buf:ident, size: $expected:expr) => {{ let available = $buf.len(); let needed = $expected; - if !(available >= needed) { - return None; + if !(needed <= available) { + return Err(RleEncodeError::BufferTooSmall); } }}; } @@ -289,9 +289,7 @@ impl RlePlaneEncoder { } else { match count { 3.. => { - written += self - .encode_segment(&raw, count, dst) - .ok_or(RleEncodeError::BufferTooSmall)?; + written += self.encode_segment(&raw, count, dst)?; raw.clear(); } 2 => raw.extend_from_slice(&[last, last]), @@ -311,14 +309,16 @@ impl RlePlaneEncoder { count = 0; } - written += self - .encode_segment(&raw, count, dst) - .ok_or(RleEncodeError::BufferTooSmall)?; + written += self.encode_segment(&raw, count, dst)?; Ok(written) } - fn encode_segment(&self, mut raw: &[u8], run: usize, dst: &mut WriteCursor<'_>) -> Option { + fn encode_segment(&self, mut raw: &[u8], run: usize, dst: &mut WriteCursor<'_>) -> Result { + if raw.is_empty() { + return Err(RleEncodeError::NotEnoughBytes); + } + let mut extra_bytes = 0; while raw.len() > 15 { @@ -334,14 +334,19 @@ impl RlePlaneEncoder { dst.write_slice(raw); if run > 15 { - let last = raw.last().unwrap(); + let last = raw.last().expect("buffer cannot be empty"); extra_bytes += self.encode_long_sequence(run - 15, *last, dst)?; } - Some(1 + raw.len() + extra_bytes) + Ok(1 + raw.len() + extra_bytes) } - fn encode_long_sequence(&self, mut run: usize, last: u8, dst: &mut WriteCursor<'_>) -> Option { + fn encode_long_sequence( + &self, + mut run: usize, + last: u8, + dst: &mut WriteCursor<'_>, + ) -> Result { let mut written = 0; while run >= 16 { @@ -370,7 +375,7 @@ impl RlePlaneEncoder { } } - Some(written) + Ok(written) } } diff --git a/crates/ironrdp-graphics/src/rlgr.rs b/crates/ironrdp-graphics/src/rlgr.rs index e6aa9cc1fb..502ef09505 100644 --- a/crates/ironrdp-graphics/src/rlgr.rs +++ b/crates/ironrdp-graphics/src/rlgr.rs @@ -4,6 +4,7 @@ use std::io; use bitvec::field::BitField as _; use bitvec::prelude::*; use ironrdp_pdu::codecs::rfx::EntropyAlgorithm; +use yuv::YuvError; use crate::utils::Bits; @@ -104,37 +105,42 @@ pub fn encode(mode: EntropyAlgorithm, input: &[i16], tile: &mut [u8]) -> Result< kp = kp.saturating_sub(DN_GR); k = kp >> LS_GR; } - CompressionMode::GolombRice => match mode { - EntropyAlgorithm::Rlgr1 => { - let two_ms = get_2magsign(*input.next().unwrap()); - code_gr(&mut bits, &mut krp, two_ms); - if two_ms == 0 { - kp = min(kp + UP_GR, KP_MAX); - } else { - kp = kp.saturating_sub(DQ_GR); - } - k = kp >> LS_GR; - } - EntropyAlgorithm::Rlgr3 => { - let two_ms1 = input.next().map(|&n| get_2magsign(n)).unwrap(); - let two_ms2 = input.next().map(|&n| get_2magsign(n)).unwrap_or(1); - let sum2ms = two_ms1 + two_ms2; - code_gr(&mut bits, &mut krp, sum2ms); - - let m = 32 - sum2ms.leading_zeros() as usize; - if m != 0 { - bits.output_bits(m, two_ms1); + CompressionMode::GolombRice => { + let input_first = *input + .next() + .expect("value is guaranteed to be `Some` due to the prior check"); + match mode { + EntropyAlgorithm::Rlgr1 => { + let two_ms = get_2magsign(input_first); + code_gr(&mut bits, &mut krp, two_ms); + if two_ms == 0 { + kp = min(kp + UP_GR, KP_MAX); + } else { + kp = kp.saturating_sub(DQ_GR); + } + k = kp >> LS_GR; } + EntropyAlgorithm::Rlgr3 => { + let two_ms1 = get_2magsign(input_first); + let two_ms2 = input.next().map(|&n| get_2magsign(n)).unwrap_or(1); + let sum2ms = two_ms1 + two_ms2; + code_gr(&mut bits, &mut krp, sum2ms); + + let m = 32 - sum2ms.leading_zeros() as usize; + if m != 0 { + bits.output_bits(m, two_ms1); + } - if two_ms1 != 0 && two_ms2 != 0 { - kp = kp.saturating_sub(2 * DQ_GR); - k = kp >> LS_GR; - } else if two_ms1 == 0 && two_ms2 == 0 { - kp = min(kp + 2 * UQ_GR, KP_MAX); - k = kp >> LS_GR; + if two_ms1 != 0 && two_ms2 != 0 { + kp = kp.saturating_sub(2 * DQ_GR); + k = kp >> LS_GR; + } else if two_ms1 == 0 && two_ms2 == 0 { + kp = min(kp + 2 * UQ_GR, KP_MAX); + k = kp >> LS_GR; + } } } - }, + } } } @@ -355,6 +361,7 @@ impl From for CompressionMode { #[derive(Debug)] pub enum RlgrError { IoError(io::Error), + YuvError(YuvError), EmptyTile, } @@ -363,6 +370,7 @@ impl core::fmt::Display for RlgrError { match self { Self::IoError(_error) => write!(f, "IO error"), Self::EmptyTile => write!(f, "the input tile is empty"), + Self::YuvError(error) => write!(f, "YUV error: {error}"), } } } @@ -371,6 +379,7 @@ impl core::error::Error for RlgrError { fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { match self { Self::IoError(error) => Some(error), + Self::YuvError(error) => Some(error), Self::EmptyTile => None, } } diff --git a/crates/ironrdp-graphics/src/zgfx/mod.rs b/crates/ironrdp-graphics/src/zgfx/mod.rs index 4e6a3e47c3..90aaa3a2c6 100644 --- a/crates/ironrdp-graphics/src/zgfx/mod.rs +++ b/crates/ironrdp-graphics/src/zgfx/mod.rs @@ -71,10 +71,15 @@ impl Decompressor { } fn decompress_segment(&mut self, encoded_data: &[u8], output: &mut Vec) -> Result { + if encoded_data.is_empty() { + return Ok(0); + } + let mut bits = BitSlice::from_slice(encoded_data); // The value of the last byte indicates the number of unused bits in the final byte - bits = &bits[..8 * (encoded_data.len() - 1) - *encoded_data.last().unwrap() as usize]; + bits = + &bits[..8 * (encoded_data.len() - 1) - *encoded_data.last().expect("encoded_data is not empty") as usize]; let mut bits = Bits::new(bits); let mut bytes_written = 0; diff --git a/crates/ironrdp-input/src/lib.rs b/crates/ironrdp-input/src/lib.rs index 628aa98c9f..d40d9e5351 100644 --- a/crates/ironrdp-input/src/lib.rs +++ b/crates/ironrdp-input/src/lib.rs @@ -362,12 +362,13 @@ impl Database { events.push(event) } + // The keyboard bit array size is 512. for idx in self.keyboard.iter_ones() { let (scancode, extended) = if idx >= 256 { let extended_code = idx.checked_sub(256).expect("never underflow"); - (u8::try_from(extended_code).unwrap(), true) + (u8::try_from(extended_code).expect("always in the range"), true) } else { - (u8::try_from(idx).unwrap(), false) + (u8::try_from(idx).expect("always in the range"), false) }; let mut flags = KeyboardFlags::RELEASE; diff --git a/crates/ironrdp-rdcleanpath/src/lib.rs b/crates/ironrdp-rdcleanpath/src/lib.rs index daff4c1cda..69bfe633d2 100644 --- a/crates/ironrdp-rdcleanpath/src/lib.rs +++ b/crates/ironrdp-rdcleanpath/src/lib.rs @@ -314,8 +314,8 @@ pub enum RDCleanPath { } impl RDCleanPath { - pub fn into_pdu(self) -> RDCleanPathPdu { - RDCleanPathPdu::from(self) + pub fn try_into_pdu(self) -> Result { + RDCleanPathPdu::try_from(self) } } @@ -368,8 +368,10 @@ impl TryFrom for RDCleanPath { } } -impl From for RDCleanPathPdu { - fn from(value: RDCleanPath) -> Self { +impl TryFrom for RDCleanPathPdu { + type Error = der::Error; + + fn try_from(value: RDCleanPath) -> Result { match value { RDCleanPath::Request { destination, @@ -377,7 +379,7 @@ impl From for RDCleanPathPdu { server_auth, preconnection_blob, x224_connection_request, - } => Self { + } => Ok(Self { version: VERSION_1, destination: Some(destination), proxy_auth: Some(proxy_auth), @@ -385,26 +387,26 @@ impl From for RDCleanPathPdu { preconnection_blob, x224_connection_pdu: Some(x224_connection_request), ..Default::default() - }, + }), RDCleanPath::Response { x224_connection_response, server_cert_chain, server_addr, - } => Self { + } => Ok(Self { version: VERSION_1, x224_connection_pdu: Some(x224_connection_response), server_cert_chain: Some(server_cert_chain), server_addr: Some(server_addr), ..Default::default() - }, - RDCleanPath::GeneralErr(error) => Self { + }), + RDCleanPath::GeneralErr(error) => Ok(Self { version: VERSION_1, error: Some(error), ..Default::default() - }, + }), RDCleanPath::NegotiationErr { x224_connection_response, - } => Self { + } => Ok(Self { version: VERSION_1, error: Some(RDCleanPathErr { error_code: NEGOTIATION_ERROR_CODE, @@ -412,9 +414,9 @@ impl From for RDCleanPathPdu { wsa_last_error: None, tls_alert_code: None, }), - x224_connection_pdu: Some(OctetString::new(x224_connection_response).unwrap()), + x224_connection_pdu: Some(OctetString::new(x224_connection_response)?), ..Default::default() - }, + }), } } } diff --git a/crates/ironrdp-rdpdr/src/pdu/efs.rs b/crates/ironrdp-rdpdr/src/pdu/efs.rs index f324cf1633..37633d4e00 100644 --- a/crates/ironrdp-rdpdr/src/pdu/efs.rs +++ b/crates/ironrdp-rdpdr/src/pdu/efs.rs @@ -1331,47 +1331,6 @@ pub struct DeviceControlResponse { pub output_buffer: Option>, } -impl PartialEq for DeviceControlResponse { - fn eq(&self, other: &Self) -> bool { - if (self.device_io_reply != other.device_io_reply) - || (self.output_buffer.is_some() != other.output_buffer.is_some()) - { - return false; - } - - // If both are `None`, they are equal. - if self.output_buffer.is_none() && other.output_buffer.is_none() { - return true; - } - - // device_io_reply is equal and both output_buffers are Some - - // If the sizes are different, the buffers are not equal. - let self_size = self.output_buffer.as_ref().unwrap().size(); - let other_size = other.output_buffer.as_ref().unwrap().size(); - if self_size != other_size { - return false; - } - - // Sizes are the same. Last check is to encode the output buffers and compare the encoded bytes directly. - let mut self_buf = vec![0u8; self_size]; - let mut other_buf = vec![0u8; other_size]; - self.output_buffer - .as_ref() - .unwrap() - .encode(&mut WriteCursor::new(self_buf.as_mut_slice())) - .unwrap(); - other - .output_buffer - .as_ref() - .unwrap() - .encode(&mut WriteCursor::new(other_buf.as_mut_slice())) - .unwrap(); - - self_buf == other_buf - } -} - impl DeviceControlResponse { const NAME: &'static str = "DR_CONTROL_RSP"; @@ -2738,11 +2697,7 @@ impl ClientDriveQueryDirectoryResponse { dst.write_u32(cast_length!( "ClientDriveQueryDirectoryResponse", "length", - if self.buffer.is_some() { - self.buffer.as_ref().unwrap().size() - } else { - 0 - } + self.buffer.as_ref().map_or(0, |buf| buf.size()) )?); if let Some(buffer) = &self.buffer { buffer.encode(dst)?; @@ -3155,11 +3110,7 @@ impl ClientDriveQueryVolumeInformationResponse { dst.write_u32(cast_length!( "ClientDriveQueryVolumeInformationResponse", "length", - if self.buffer.is_some() { - self.buffer.as_ref().unwrap().size() - } else { - 0 - } + self.buffer.as_ref().map_or(0, |buf| buf.size()) )?); if let Some(buffer) = &self.buffer { buffer.encode(dst)?; diff --git a/crates/ironrdp-rdpdr/src/pdu/esc.rs b/crates/ironrdp-rdpdr/src/pdu/esc.rs index 82c584bdf2..b7deefde63 100644 --- a/crates/ironrdp-rdpdr/src/pdu/esc.rs +++ b/crates/ironrdp-rdpdr/src/pdu/esc.rs @@ -1836,10 +1836,7 @@ impl rpce::HeaderlessEncode for GetReaderIconReturn { } fn expect_charset(charset: Option) -> DecodeResult { - if charset.is_none() { - return Err(other_err!("internal error: missing character set")); - } - Ok(charset.unwrap()) + charset.ok_or_else(|| other_err!("internal error: missing character set")) } fn expect_no_charset(charset: Option) -> DecodeResult<()> { diff --git a/crates/ironrdp-rdpsnd-native/examples/cpal.rs b/crates/ironrdp-rdpsnd-native/examples/cpal.rs index e7d2f47960..9486abbe62 100644 --- a/crates/ironrdp-rdpsnd-native/examples/cpal.rs +++ b/crates/ironrdp-rdpsnd-native/examples/cpal.rs @@ -43,7 +43,7 @@ fn main() -> anyhow::Result<()> { data: None, }; let (tx, rx) = mpsc::channel(); - let stream = DecodeStream::new(&rx_format, rx).unwrap(); + let stream = DecodeStream::new(&rx_format, rx)?; let producer = thread::spawn(move || { let data_chunks = vec![vec![1u8, 2, 3], vec![4, 5, 6], vec![7, 8, 9]]; diff --git a/crates/ironrdp-rdpsnd-native/src/cpal.rs b/crates/ironrdp-rdpsnd-native/src/cpal.rs index 831b6aa51e..c3bddb4cfa 100644 --- a/crates/ironrdp-rdpsnd-native/src/cpal.rs +++ b/crates/ironrdp-rdpsnd-native/src/cpal.rs @@ -124,7 +124,9 @@ impl RdpsndClientHandler for RdpsndBackend { if let Some(stream) = self.stream_handle.take() { self.stream_ended.store(true, Ordering::Relaxed); stream.thread().unpark(); - stream.join().unwrap(); + if let Err(err) = stream.join() { + error!(?err, "Failed to join a stream thread"); + } } } } @@ -150,11 +152,26 @@ impl DecodeStream { let mut dec = opus::Decoder::new(rx_format.n_samples_per_sec, chan)?; dec_thread = Some(thread::spawn(move || { while let Ok(pkt) = rx.recv() { - let nb_samples = dec.get_nb_samples(&pkt).unwrap(); + let nb_samples = match dec.get_nb_samples(&pkt) { + Ok(nb_samples) => nb_samples, + Err(err) => { + error!(?err, "Failed to get the number of samples of an Opus packet"); + continue; + } + }; + let mut pcm = vec![0u8; nb_samples * chan as usize * size_of::()]; - dec.decode(&pkt, bytemuck::cast_slice_mut(pcm.as_mut_slice()), false) - .unwrap(); - dec_tx.send(pcm).unwrap(); + if let Err(err) = dec.decode(&pkt, bytemuck::cast_slice_mut(pcm.as_mut_slice()), false) { + error!(?err, "Failed to decode an Opus packet"); + continue; + } + + if dec_tx.send(pcm).is_err() { + error!("Failed to send the decoded Opus packet over the channel"); + // If send has failed, it means that the receiver has been dropped. + // There is no point in continuing the loop in this case. + break; + } } })); rx = dec_rx; diff --git a/crates/ironrdp-rdpsnd/src/pdu/mod.rs b/crates/ironrdp-rdpsnd/src/pdu/mod.rs index d5f3893dbd..51e1b26d2b 100644 --- a/crates/ironrdp-rdpsnd/src/pdu/mod.rs +++ b/crates/ironrdp-rdpsnd/src/pdu/mod.rs @@ -950,7 +950,7 @@ impl Encode for WaveEncryptPdu { fn size(&self) -> usize { Self::FIXED_PART_SIZE .checked_add(self.signature.map_or(0, |_| 8)) - .unwrap() + .expect("never overflow") .checked_add(self.data.len()) .expect("never overflow") } diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index 4d43736785..40bdca80b7 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -125,13 +125,13 @@ impl RdpServerBuilder { display: Box::new(display), sound_factory: None, cliprdr_factory: None, - codecs: server_codecs_capabilities(&[]).unwrap(), + codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), }, } } - pub fn with_no_display(self) -> RdpServerBuilder { - RdpServerBuilder { + pub fn with_no_display(self) -> Result> { + Ok(RdpServerBuilder { state: BuilderDone { addr: self.state.addr, security: self.state.security, @@ -139,9 +139,9 @@ impl RdpServerBuilder { display: Box::new(NoopDisplay), sound_factory: None, cliprdr_factory: None, - codecs: server_codecs_capabilities(&[]).unwrap(), + codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), }, - } + }) } } @@ -187,7 +187,7 @@ struct NoopDisplayUpdates; #[async_trait::async_trait] impl RdpServerDisplayUpdates for NoopDisplayUpdates { - async fn next_update(&mut self) -> Option { + async fn next_update(&mut self) -> Result> { let () = core::future::pending().await; unreachable!() } diff --git a/crates/ironrdp-server/src/display.rs b/crates/ironrdp-server/src/display.rs index c0b7c3b0dd..2450b20170 100644 --- a/crates/ironrdp-server/src/display.rs +++ b/crates/ironrdp-server/src/display.rs @@ -243,7 +243,7 @@ pub trait RdpServerDisplayUpdates { /// This method MUST be cancellation safe because it is used in a /// `tokio::select!` statement. If some other branch completes first, it /// MUST be guaranteed that no data is lost. - async fn next_update(&mut self) -> Option; + async fn next_update(&mut self) -> Result>; } /// Display for an RDP server @@ -260,8 +260,8 @@ pub trait RdpServerDisplayUpdates { /// /// #[async_trait::async_trait] /// impl RdpServerDisplayUpdates for DisplayUpdates { -/// async fn next_update(&mut self) -> Option { -/// self.receiver.recv().await +/// async fn next_update(&mut self) -> anyhow::Result> { +/// Ok(self.receiver.recv().await) /// } /// } /// diff --git a/crates/ironrdp-server/src/encoder/bitmap.rs b/crates/ironrdp-server/src/encoder/bitmap.rs index 9a1647d0b3..e7a78c7b46 100644 --- a/crates/ironrdp-server/src/encoder/bitmap.rs +++ b/crates/ironrdp-server/src/encoder/bitmap.rs @@ -1,8 +1,10 @@ use core::num::NonZeroUsize; -use ironrdp_core::{invalid_field_err, Encode as _, EncodeResult, WriteCursor}; +use ironrdp_core::{cast_int, cast_length, invalid_field_err, Encode as _, WriteCursor}; use ironrdp_graphics::image_processing::PixelFormat; -use ironrdp_graphics::rdp6::{ABgrChannels, ARgbChannels, BgrAChannels, BitmapStreamEncoder, RgbAChannels}; +use ironrdp_graphics::rdp6::{ + ABgrChannels, ARgbChannels, BgrAChannels, BitmapEncodeError, BitmapStreamEncoder, RgbAChannels, +}; use ironrdp_pdu::bitmap::{self, BitmapData, BitmapUpdateData, Compression}; use ironrdp_pdu::geometry::InclusiveRectangle; @@ -21,84 +23,95 @@ impl BitmapEncoder { } } - pub(crate) fn encode(&mut self, bitmap: &BitmapUpdate, output: &mut [u8]) -> EncodeResult { + pub(crate) fn encode(&mut self, bitmap: &BitmapUpdate, output: &mut [u8]) -> Result { // FIXME: support non-multiple of 4 widths. // // It’s not clear how to achieve that yet, but generally, server uses multiple of 4-widths, // and client has surface capabilities, so this path is unlikely. if bitmap.width.get() % 4 != 0 { - return Err(invalid_field_err!("bitmap", "Width must be a multiple of 4")); + return Err(BitmapEncodeError::Encode(invalid_field_err!( + "bitmap", + "Width must be a multiple of 4" + ))); } - let bytes_per_pixel = usize::from(bitmap.format.bytes_per_pixel()); - let row_len = usize::from(bitmap.width.get()) * bytes_per_pixel; - let chunk_height = usize::from(u16::MAX) / row_len; + let bytes_per_pixel = u16::from(bitmap.format.bytes_per_pixel()); + let row_len = bitmap.width.get() * bytes_per_pixel; + let chunk_height = u16::MAX / row_len; let mut cursor = WriteCursor::new(output); let stride = bitmap.stride.get(); - let chunks = bitmap.data.chunks(stride * chunk_height); + let chunks = bitmap.data.chunks(stride * usize::from(chunk_height)); - let total = u16::try_from(chunks.size_hint().0).unwrap(); - BitmapUpdateData::encode_header(total, &mut cursor)?; + let total = cast_int!("chunks length lower bound", chunks.size_hint().0).map_err(BitmapEncodeError::Encode)?; + BitmapUpdateData::encode_header(total, &mut cursor).map_err(BitmapEncodeError::Encode)?; for (i, chunk) in chunks.enumerate() { - let height = chunk.len() / stride; - let top = usize::from(bitmap.y) + i * chunk_height; + let height = cast_int!("bitmap height", chunk.len() / stride).map_err(BitmapEncodeError::Encode)?; + let i: u16 = cast_int!("chunk idx", i).map_err(BitmapEncodeError::Encode)?; + let top = bitmap.y + i * chunk_height; - let encoder = BitmapStreamEncoder::new(NonZeroUsize::from(bitmap.width).get(), height); + let encoder = BitmapStreamEncoder::new(NonZeroUsize::from(bitmap.width).get(), usize::from(height)); let len = { let pixels = chunk .chunks(stride) - .map(|row| &row[..row_len]) + .map(|row| &row[..usize::from(row_len)]) .rev() - .flat_map(|row| row.chunks(bytes_per_pixel)); + .flat_map(|row| row.chunks(usize::from(bytes_per_pixel))); - Self::encode_iter(encoder, bitmap.format, pixels, self.buffer.as_mut_slice()) + Self::encode_iter(encoder, bitmap.format, pixels, self.buffer.as_mut_slice())? }; let data = BitmapData { rectangle: InclusiveRectangle { left: bitmap.x, - top: u16::try_from(top).unwrap(), + top, right: bitmap.x + bitmap.width.get() - 1, - bottom: u16::try_from(top + height - 1).unwrap(), + bottom: top + height - 1, }, width: u16::from(bitmap.width), - height: u16::try_from(height).unwrap(), + height, bits_per_pixel: u16::from(bitmap.format.bytes_per_pixel()) * 8, compression_flags: Compression::BITMAP_COMPRESSION, compressed_data_header: Some(bitmap::CompressedDataHeader { - main_body_size: u16::try_from(len).unwrap(), + main_body_size: cast_length!("main body size", len).map_err(BitmapEncodeError::Encode)?, scan_width: u16::from(bitmap.width), - uncompressed_size: u16::try_from(height * row_len).unwrap(), + uncompressed_size: height * row_len, }), bitmap_data: &self.buffer[..len], }; - data.encode(&mut cursor)?; + data.encode(&mut cursor).map_err(BitmapEncodeError::Encode)?; } Ok(cursor.pos()) } - fn encode_iter<'a, P>(mut encoder: BitmapStreamEncoder, format: PixelFormat, src: P, dst: &mut [u8]) -> usize + fn encode_iter<'a, P>( + mut encoder: BitmapStreamEncoder, + format: PixelFormat, + src: P, + dst: &mut [u8], + ) -> Result where P: Iterator + Clone, { - match format { + let written = match format { PixelFormat::ARgb32 | PixelFormat::XRgb32 => { - encoder.encode_pixels_stream::<_, ARgbChannels>(src, dst, true).unwrap() + encoder.encode_pixels_stream::<_, ARgbChannels>(src, dst, true)? } PixelFormat::RgbA32 | PixelFormat::RgbX32 => { - encoder.encode_pixels_stream::<_, RgbAChannels>(src, dst, true).unwrap() + encoder.encode_pixels_stream::<_, RgbAChannels>(src, dst, true)? } PixelFormat::ABgr32 | PixelFormat::XBgr32 => { - encoder.encode_pixels_stream::<_, ABgrChannels>(src, dst, true).unwrap() + encoder.encode_pixels_stream::<_, ABgrChannels>(src, dst, true)? } PixelFormat::BgrA32 | PixelFormat::BgrX32 => { - encoder.encode_pixels_stream::<_, BgrAChannels>(src, dst, true).unwrap() + encoder.encode_pixels_stream::<_, BgrAChannels>(src, dst, true)? } - } + }; + + Ok(written) } } diff --git a/crates/ironrdp-server/src/encoder/mod.rs b/crates/ironrdp-server/src/encoder/mod.rs index 520cf97d81..7907b83178 100644 --- a/crates/ironrdp-server/src/encoder/mod.rs +++ b/crates/ironrdp-server/src/encoder/mod.rs @@ -1,7 +1,7 @@ use core::fmt; use core::num::NonZeroU16; -use anyhow::{Context as _, Result}; +use anyhow::{anyhow, Context as _, Result}; use ironrdp_acceptor::DesktopSize; use ironrdp_graphics::diff::{find_different_rects_sub, Rect}; use ironrdp_pdu::encode_vec; @@ -23,6 +23,7 @@ mod fast_path; pub(crate) mod rfx; pub(crate) use fast_path::*; +use ironrdp_graphics::rdp6::BitmapEncodeError; #[derive(Debug, Copy, Clone, PartialEq, Eq)] #[repr(u8)] @@ -93,7 +94,7 @@ impl fmt::Debug for UpdateEncoder { impl UpdateEncoder { #[cfg_attr(feature = "__bench", visibility::make(pub))] - pub(crate) fn new(desktop_size: DesktopSize, surface_flags: CmdFlags, codecs: UpdateEncoderCodecs) -> Self { + pub(crate) fn new(desktop_size: DesktopSize, surface_flags: CmdFlags, codecs: UpdateEncoderCodecs) -> Result { let bitmap_updater = if surface_flags.contains(CmdFlags::SET_SURFACE_BITS) { let mut bitmap = BitmapUpdater::None(NoneHandler); @@ -107,7 +108,7 @@ impl UpdateEncoder { } #[cfg(feature = "qoiz")] if let Some(id) = codecs.qoiz { - bitmap = BitmapUpdater::Qoiz(QoizHandler::new(id)); + bitmap = BitmapUpdater::Qoiz(QoizHandler::new(id).context("failed to initialize qoiz handler")?); } bitmap @@ -115,11 +116,11 @@ impl UpdateEncoder { BitmapUpdater::Bitmap(BitmapHandler::new()) }; - Self { + Ok(Self { desktop_size, framebuffer: None, bitmap_updater: Some(bitmap_updater), - } + }) } #[cfg_attr(feature = "__bench", visibility::make(pub))] @@ -246,8 +247,7 @@ impl UpdateEncoder { let result = time_warn!("Encoding bitmap", 10, updater.handle(&bitmap)); (result, updater) }) - .await - .unwrap(); + .await?; self.bitmap_updater = Some(updater); @@ -301,12 +301,31 @@ impl EncoderIter<'_> { return None; }; let Rect { x, y, width, height } = *rect; - let Some(sub) = bitmap.sub( - u16::try_from(x).unwrap(), - u16::try_from(y).unwrap(), - NonZeroU16::new(u16::try_from(width).unwrap()).unwrap(), - NonZeroU16::new(u16::try_from(height).unwrap()).unwrap(), - ) else { + + let x = match u16::try_from(x) { + Ok(x) => x, + Err(_) => return Some(Err(anyhow!("invalid `x`: out of range integral conversion"))), + }; + let y = match u16::try_from(y) { + Ok(y) => y, + Err(_) => return Some(Err(anyhow!("invalid `y`: out of range integral conversion"))), + }; + let width = match u16::try_from(width) { + Ok(width) => match NonZeroU16::new(width) { + Some(width) => width, + None => return Some(Err(anyhow!("rectangle width cannot be zero"))), + }, + Err(_) => return Some(Err(anyhow!("invalid `width`: out of range integral conversion"))), + }; + let height = match u16::try_from(height) { + Ok(height) => match NonZeroU16::new(height) { + Some(height) => height, + None => return Some(Err(anyhow!("rectangle height cannot be zero"))), + }, + Err(_) => return Some(Err(anyhow!("invalid `height`: out of range integral conversion"))), + }; + + let Some(sub) = bitmap.sub(x, y, width, height) else { warn!("Failed to extract bitmap subregion"); return None; }; @@ -398,13 +417,15 @@ impl BitmapUpdateHandler for BitmapHandler { let mut buffer = vec![0; bitmap.data.len() * 2]; // TODO: estimate bitmap encoded size let len = loop { match self.bitmap.encode(bitmap, buffer.as_mut_slice()) { - Err(e) => match e.kind() { - ironrdp_core::EncodeErrorKind::NotEnoughBytes { .. } => { - buffer.resize(buffer.len() * 2, 0); - debug!("encoder buffer resized to: {}", buffer.len() * 2); - } - - _ => Err(e).context("bitmap encode error")?, + Err(err) => match err { + BitmapEncodeError::Encode(e) => match e.kind() { + ironrdp_core::EncodeErrorKind::NotEnoughBytes { .. } => { + buffer.resize(buffer.len() * 2, 0); + debug!("encoder buffer resized to: {}", buffer.len() * 2); + } + _ => Err(e).context("bitmap encode error")?, + }, + BitmapEncodeError::Rle(e) => Err(e).context("bitmap RLE encode error")?, }, Ok(len) => break len, } @@ -495,15 +516,27 @@ impl fmt::Debug for QoizHandler { #[cfg(feature = "qoiz")] impl QoizHandler { - fn new(codec_id: u8) -> Self { + fn new(codec_id: u8) -> Result { let mut zctxt = zstd_safe::CCtx::default(); - zctxt.set_parameter(zstd_safe::CParameter::CompressionLevel(3)).unwrap(); + zctxt + .set_parameter(zstd_safe::CParameter::CompressionLevel(3)) + .map_err(|code| { + anyhow!( + "failed to set Zstd compression level: {}", + zstd_safe::get_error_name(code) + ) + })?; zctxt .set_parameter(zstd_safe::CParameter::EnableLongDistanceMatching(true)) - .unwrap(); + .map_err(|code| { + anyhow!( + "failed to set Zstd enable long distance matching: {}", + zstd_safe::get_error_name(code) + ) + })?; - Self { codec_id, zctxt } + Ok(Self { codec_id, zctxt }) } } @@ -525,7 +558,7 @@ impl BitmapUpdateHandler for QoizHandler { &mut inb, zstd_safe::zstd_sys::ZSTD_EndDirective::ZSTD_e_flush, ) - .map_err(|code| anyhow::anyhow!("failed to zstd compress: {}", zstd_safe::get_error_name(code)))?; + .map_err(|code| anyhow!("failed to Zstd compress: {}", zstd_safe::get_error_name(code)))?; if res == 0 { break; } diff --git a/crates/ironrdp-server/src/encoder/rfx.rs b/crates/ironrdp-server/src/encoder/rfx.rs index 4f9cf2894f..956c8e648d 100644 --- a/crates/ironrdp-server/src/encoder/rfx.rs +++ b/crates/ironrdp-server/src/encoder/rfx.rs @@ -1,5 +1,7 @@ +use std::io; + use ironrdp_acceptor::DesktopSize; -use ironrdp_core::{cast_length, other_err, Encode as _, EncodeResult}; +use ironrdp_core::{cast_int, cast_length, other_err, Encode as _, EncodeResult}; use ironrdp_graphics::color_conversion::to_64x64_ycbcr_tile; use ironrdp_graphics::rfx_encode_component; use ironrdp_graphics::rlgr::RlgrError; @@ -164,8 +166,8 @@ impl<'a> UpdateEncoder<'a> { y_quant_index: 0, cb_quant_index: 0, cr_quant_index: 0, - x: u16::try_from(tile_x).unwrap(), - y: u16::try_from(tile_y).unwrap(), + x: cast_int!("tile_x", tile_x)?, + y: cast_int!("tile_y", tile_y)?, y_data, cb_data, cr_data, @@ -186,15 +188,18 @@ impl<'a> UpdateEncoder<'a> { let x = tile_x * 64; let y = tile_y * 64; - let tile_width = core::cmp::min(width - x, 64); - let tile_height = core::cmp::min(height - y, 64); + let tile_width = u32::try_from(core::cmp::min(width - x, 64)).expect("can always fit in u32"); + let tile_height = u32::try_from(core::cmp::min(height - y, 64)).expect("can always fit in u32"); let stride = self.bitmap.stride.get(); let input = &self.bitmap.data[y * stride + x * bpp..]; + let stride = u32::try_from(stride).map_err(io::Error::other)?; let y = &mut [0i16; 4096]; let cb = &mut [0i16; 4096]; let cr = &mut [0i16; 4096]; - to_64x64_ycbcr_tile(input, tile_width, tile_height, stride, self.bitmap.format, y, cb, cr); + + to_64x64_ycbcr_tile(input, tile_width, tile_height, stride, self.bitmap.format, y, cb, cr) + .map_err(RlgrError::YuvError)?; let (y_data, buf) = buf.split_at_mut(4096); let (cb_data, cr_data) = buf.split_at_mut(4096); @@ -227,12 +232,13 @@ pub(crate) mod bench { ) { let (enc, mut data) = UpdateEncoder::new(bitmap, quant.clone(), algo); - enc.encode_tile(tile_x, tile_y, &mut data.0).unwrap(); + enc.encode_tile(tile_x, tile_y, &mut data.0) + .expect("cannot propagate error in benchmark"); } pub fn rfx_enc(bitmap: &BitmapUpdate, quant: &Quant, algo: rfx::EntropyAlgorithm) { let (enc, mut data) = UpdateEncoder::new(bitmap, quant.clone(), algo); - enc.encode(&mut data).unwrap(); + enc.encode(&mut data).expect("cannot propagate error in benchmark"); } } diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 190d6a816f..101ea4066b 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -178,7 +178,7 @@ impl DisplayControlHandler for DisplayControlBackend { ///# todo!() ///# } ///# } -///# async fn stub() { +///# async fn stub() -> Result<()> { /// fn make_tls_acceptor() -> TlsAcceptor { /// /* snip */ ///# todo!() @@ -206,6 +206,7 @@ impl DisplayControlHandler for DisplayControlBackend { /// .build(); /// /// server.run().await; +/// Ok(()) ///# } /// ``` pub struct RdpServer { @@ -601,28 +602,35 @@ impl RdpServer { let dispatch_display = async move { let mut buffer = vec![0u8; 4096]; + loop { - if let Some(update) = display_updates.next_update().await { - match Self::dispatch_display_update( - update, - &mut display_writer, - user_channel_id, - io_channel_id, - &mut buffer, - encoder, - ) - .await? - { - (RunState::Continue, enc) => { - encoder = enc; - continue; - } - (state, _) => { - break Ok(state); + match display_updates.next_update().await { + Ok(Some(update)) => { + match Self::dispatch_display_update( + update, + &mut display_writer, + user_channel_id, + io_channel_id, + &mut buffer, + encoder, + ) + .await? + { + (RunState::Continue, enc) => { + encoder = enc; + continue; + } + (state, _) => { + break Ok(state); + } } } - } else { - break Ok(RunState::Disconnect); + Ok(None) => { + break Ok(RunState::Disconnect); + } + Err(error) => { + warn!(error = format!("{error:#}"), "next_updated failed"); + } } } }; @@ -776,7 +784,8 @@ impl RdpServer { } let desktop_size = self.display.lock().await.size().await; - let encoder = UpdateEncoder::new(desktop_size, surface_flags, update_codecs); + let encoder = UpdateEncoder::new(desktop_size, surface_flags, update_codecs) + .context("failed to initialize update encoder")?; let state = self .client_loop(reader, writer, result.io_channel_id, result.user_channel_id, encoder) diff --git a/crates/ironrdp-session/src/active_stage.rs b/crates/ironrdp-session/src/active_stage.rs index 234071aecb..5ae3f80e3d 100644 --- a/crates/ironrdp-session/src/active_stage.rs +++ b/crates/ironrdp-session/src/active_stage.rs @@ -224,9 +224,8 @@ impl ActiveStage { physical_dims: Option<(u32, u32)>, ) -> Option>> { if let Some(dvc) = self.get_dvc::() { - if dvc.is_open() { + if let Some(channel_id) = dvc.channel_id() { let display_control = dvc.channel_processor_downcast_ref::()?; - let channel_id = dvc.channel_id().unwrap(); // Safe to unwrap, as we checked if the channel is open let svc_messages = match display_control.encode_single_primary_monitor( channel_id, width, diff --git a/crates/ironrdp-session/src/image.rs b/crates/ironrdp-session/src/image.rs index 9da0379e84..7e27c3f126 100644 --- a/crates/ironrdp-session/src/image.rs +++ b/crates/ironrdp-session/src/image.rs @@ -554,7 +554,11 @@ impl DecodedImage { row.chunks_exact(SRC_COLOR_DEPTH) .enumerate() .for_each(|(col_idx, src_pixel)| { - let rgb16_value = u16::from_le_bytes(src_pixel.try_into().unwrap()); + let rgb16_value = u16::from_le_bytes( + src_pixel + .try_into() + .expect("src_pixel contains exactly two u8 elements"), + ); let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; let [r, g, b] = rdp_16bit_to_rgb(rgb16_value); @@ -658,16 +662,22 @@ impl DecodedImage { .chunks_exact(rectangle_width * SRC_COLOR_DEPTH) .rev() .enumerate() - .for_each(|(row_idx, row)| { + .try_for_each(|(row_idx, row)| { row.chunks_exact(SRC_COLOR_DEPTH) .enumerate() - .for_each(|(col_idx, src_pixel)| { + .try_for_each(|(col_idx, src_pixel)| { let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; - let c = format.read_color(src_pixel).unwrap(); + let c = format + .read_color(src_pixel) + .map_err(|err| custom_err!("read color", err))?; self.data[dst_idx..dst_idx + SRC_COLOR_DEPTH].copy_from_slice(&[c.r, c.g, c.b, c.a]); - }) - }); + + Ok(()) + })?; + + Ok(()) + })?; } let update_rectangle = self.pointer_rendering_end(pointer_rendering_state)?; diff --git a/crates/ironrdp-session/src/rfx.rs b/crates/ironrdp-session/src/rfx.rs index 89edea4521..7bd21d6d0d 100644 --- a/crates/ironrdp-session/src/rfx.rs +++ b/crates/ironrdp-session/src/rfx.rs @@ -108,7 +108,11 @@ impl DecodingContext { image: &mut DecodedImage, destination: &InclusiveRectangle, ) -> SessionResult<(FrameId, InclusiveRectangle)> { - let channel = self.channels.0.first().unwrap(); + let channel = self + .channels + .0 + .first() + .ok_or_else(|| general_err!("no RFX channel found"))?; let width = channel.width.try_into().map_err(|_| general_err!("invalid width"))?; let height = channel.height.try_into().map_err(|_| general_err!("invalid height"))?; let entropy_algorithm = self.context.entropy_algorithm; diff --git a/crates/ironrdp-testsuite-core/src/lib.rs b/crates/ironrdp-testsuite-core/src/lib.rs index aeb08ee537..3318fa4931 100644 --- a/crates/ironrdp-testsuite-core/src/lib.rs +++ b/crates/ironrdp-testsuite-core/src/lib.rs @@ -5,6 +5,7 @@ #![allow(clippy::cast_possible_wrap)] #![allow(clippy::cast_sign_loss)] #![allow(unused_crate_dependencies)] +#![allow(clippy::unwrap_used, reason = "unwrap is fine in tests")] mod macros; diff --git a/crates/ironrdp-testsuite-core/tests/graphics/color_conversion.rs b/crates/ironrdp-testsuite-core/tests/graphics/color_conversion.rs index 585eb6a9ad..1e63d887f4 100644 --- a/crates/ironrdp-testsuite-core/tests/graphics/color_conversion.rs +++ b/crates/ironrdp-testsuite-core/tests/graphics/color_conversion.rs @@ -8,7 +8,7 @@ fn to_64x64_ycbcr() { let mut y = [0; 64 * 64]; let mut cb = [0; 64 * 64]; let mut cr = [0; 64 * 64]; - to_64x64_ycbcr_tile(&input, 1, 1, 4, PixelFormat::ABgr32, &mut y, &mut cb, &mut cr); + to_64x64_ycbcr_tile(&input, 1, 1, 4, PixelFormat::ABgr32, &mut y, &mut cb, &mut cr).unwrap(); } #[ignore] @@ -24,7 +24,7 @@ fn rgb_to_ycbcr_converts_large_buffer() { let mut y = [0; 4096]; let mut cb = [0; 4096]; let mut cr = [0; 4096]; - to_64x64_ycbcr_tile(xrgb, 64, 64, 64 * 4, PixelFormat::XRgb32, &mut y, &mut cb, &mut cr); + to_64x64_ycbcr_tile(xrgb, 64, 64, 64 * 4, PixelFormat::XRgb32, &mut y, &mut cb, &mut cr).unwrap(); assert_eq!(expected.y, y.as_slice()); } diff --git a/crates/ironrdp-testsuite-core/tests/main.rs b/crates/ironrdp-testsuite-core/tests/main.rs index 246feea82c..ba011f85c5 100644 --- a/crates/ironrdp-testsuite-core/tests/main.rs +++ b/crates/ironrdp-testsuite-core/tests/main.rs @@ -1,5 +1,6 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary #![allow(clippy::panic, reason = "panic is acceptable in tests")] +#![allow(clippy::unwrap_used, reason = "unwrap is fine in tests")] //! Integration Tests (IT) //! //! Integration tests are all contained in this single crate, and organized in modules. diff --git a/crates/ironrdp-testsuite-extra/tests/tests.rs b/crates/ironrdp-testsuite-extra/tests/tests.rs index 29bd2af2f4..7127fa5f1e 100644 --- a/crates/ironrdp-testsuite-extra/tests/tests.rs +++ b/crates/ironrdp-testsuite-extra/tests/tests.rs @@ -1,4 +1,5 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary +#![allow(clippy::unwrap_used, reason = "unwrap is fine in tests")] use core::future::Future; use std::path::Path; @@ -122,10 +123,10 @@ struct TestDisplayUpdates { #[async_trait::async_trait] impl RdpServerDisplayUpdates for TestDisplayUpdates { - async fn next_update(&mut self) -> Option { + async fn next_update(&mut self) -> Result> { let mut rx = self.rx.lock().await; - rx.recv().await + Ok(rx.recv().await) } } diff --git a/crates/ironrdp-web/src/canvas.rs b/crates/ironrdp-web/src/canvas.rs index 322c050682..54cfbb2a34 100644 --- a/crates/ironrdp-web/src/canvas.rs +++ b/crates/ironrdp-web/src/canvas.rs @@ -5,14 +5,14 @@ use softbuffer::{NoDisplayHandle, NoWindowHandle}; use web_sys::HtmlCanvasElement; pub(crate) struct Canvas { - width: u32, + width: NonZeroU32, surface: softbuffer::Surface, } impl Canvas { - pub(crate) fn new(render_canvas: HtmlCanvasElement, width: u32, height: u32) -> anyhow::Result { - render_canvas.set_width(width); - render_canvas.set_height(height); + pub(crate) fn new(render_canvas: HtmlCanvasElement, width: NonZeroU32, height: NonZeroU32) -> anyhow::Result { + render_canvas.set_width(width.get()); + render_canvas.set_height(height.get()); #[cfg(target_arch = "wasm32")] let mut surface = { @@ -29,16 +29,14 @@ impl Canvas { stub(render_canvas) }; - surface - .resize(NonZeroU32::new(width).unwrap(), NonZeroU32::new(height).unwrap()) - .expect("surface resize"); + surface.resize(width, height).expect("surface resize"); Ok(Self { width, surface }) } pub(crate) fn resize(&mut self, width: NonZeroU32, height: NonZeroU32) { self.surface.resize(width, height).expect("surface resize"); - self.width = width.get(); + self.width = width; } pub(crate) fn draw(&mut self, buffer: &[u8], region: InclusiveRectangle) -> anyhow::Result<()> { @@ -63,7 +61,7 @@ impl Canvas { let region_width_usize = usize::from(region_width); for dst_row in dst - .chunks_exact_mut(self.width as usize) + .chunks_exact_mut(self.width.get() as usize) .skip(region_top_usize) .take(region_height_usize) { @@ -81,8 +79,8 @@ impl Canvas { let damage_rect = softbuffer::Rect { x: u32::from(region.left), y: u32::from(region.top), - width: NonZeroU32::new(u32::from(region_width)).unwrap(), - height: NonZeroU32::new(u32::from(region_height)).unwrap(), + width: NonZeroU32::new(u32::from(region_width)).expect("per invariants: 0 < region_width"), + height: NonZeroU32::new(u32::from(region_height)).expect("per invariants: 0 < region_height"), }; dst.present_with_damage(&[damage_rect]).expect("buffer present"); diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index a29ee2e1b5..e5933bad38 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -477,12 +477,13 @@ impl iron_remote_desktop::Session for Session { debug!("Initialize canvas"); - let mut gui = Canvas::new( - self.render_canvas.clone(), - u32::from(connection_result.desktop_size.width), - u32::from(connection_result.desktop_size.height), - ) - .context("canvas initialization")?; + let desktop_width = + NonZeroU32::new(u32::from(connection_result.desktop_size.width)).context("desktop width is zero")?; + let desktop_height = + NonZeroU32::new(u32::from(connection_result.desktop_size.height)).context("desktop height is zero")?; + + let mut gui = + Canvas::new(self.render_canvas.clone(), desktop_width, desktop_height).context("canvas initialization")?; debug!("Canvas initialized"); @@ -559,7 +560,10 @@ impl iron_remote_desktop::Session for Session { warn!("Resize event ignored: width or height is zero"); Vec::new() } else if let Some(response_frame) = active_stage.encode_resize(width, height, scale_factor, physical_size) { - requested_resize = Some((NonZeroU32::new(width).unwrap(), NonZeroU32::new(height).unwrap())); + let width = NonZeroU32::new(width).expect("width is guaranteed to be non-zero due to the prior check"); + let height = NonZeroU32::new(height).expect("height is guaranteed to be non-zero due to the prior check"); + + requested_resize = Some((width, height)); vec![ActiveStageOutput::ResponseFrame(response_frame?)] } else { debug!("Resize event ignored"); @@ -864,14 +868,16 @@ fn build_config( bitmap: Some(connector::BitmapConfig { color_depth: 16, lossy_compression: true, - codecs: client_codecs_capabilities(&[]).unwrap(), + codecs: client_codecs_capabilities(&[]).expect("can't panic for &[]"), }), - #[expect(clippy::arithmetic_side_effects)] // fine unless we end up with an insanely big version + #[expect( + clippy::arithmetic_side_effects, + reason = "fine unless we end up with an insanely big version" + )] client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map(|version| version.major * 100 + version.minor * 10 + version.patch) - .unwrap_or(0) + .map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch) .pipe(u32::try_from) - .unwrap(), + .expect("fine until major ~42949672"), client_name, // NOTE: hardcode this value like in freerdp // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 diff --git a/crates/ironrdp/examples/screenshot.rs b/crates/ironrdp/examples/screenshot.rs index 431b86a291..94144ce6a3 100644 --- a/crates/ironrdp/examples/screenshot.rs +++ b/crates/ironrdp/examples/screenshot.rs @@ -302,7 +302,10 @@ fn active_stage( fn lookup_addr(hostname: &str, port: u16) -> anyhow::Result { use std::net::ToSocketAddrs as _; - let addr = (hostname, port).to_socket_addrs()?.next().unwrap(); + let addr = (hostname, port) + .to_socket_addrs()? + .next() + .context("socket address not found")?; Ok(addr) } @@ -327,7 +330,7 @@ fn tls_upgrade( let config = std::sync::Arc::new(config); - let server_name = server_name.try_into().unwrap(); + let server_name = server_name.try_into()?; let client = rustls::ClientConnection::new(config, server_name)?; diff --git a/crates/ironrdp/examples/server.rs b/crates/ironrdp/examples/server.rs index 57e488e9a1..dde826ba1a 100644 --- a/crates/ironrdp/examples/server.rs +++ b/crates/ironrdp/examples/server.rs @@ -4,7 +4,7 @@ #![allow(clippy::print_stdout)] use core::net::SocketAddr; -use core::num::{NonZero, NonZeroU16, NonZeroUsize}; +use core::num::{NonZeroU16, NonZeroUsize}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -153,21 +153,24 @@ struct DisplayUpdates; #[async_trait::async_trait] impl RdpServerDisplayUpdates for DisplayUpdates { - async fn next_update(&mut self) -> Option { + async fn next_update(&mut self) -> anyhow::Result> { sleep(Duration::from_millis(100)).await; let mut rng = rand::rng(); let y: u16 = rng.random_range(0..HEIGHT); - let height = NonZeroU16::new(rng.random_range(1..=HEIGHT.checked_sub(y).unwrap())).unwrap(); + let height = rng.random_range(1..=HEIGHT.checked_sub(y).expect("never underflow")); + let height = NonZeroU16::new(height).expect("never zero"); + let x: u16 = rng.random_range(0..WIDTH); - let width = NonZeroU16::new(rng.random_range(1..=WIDTH.checked_sub(x).unwrap())).unwrap(); + let width = rng.random_range(1..=WIDTH.checked_sub(x).expect("never underflow")); + let width = NonZeroU16::new(width).expect("never zero"); + let capacity = NonZeroUsize::from(width) .checked_mul(NonZeroUsize::from(height)) - .unwrap() + .expect("never overflow") .get() .checked_mul(4) - .unwrap(); - + .expect("never overflow"); let mut data = Vec::with_capacity(capacity); for _ in 0..(data.capacity() / 4) { data.push(rng.random()); @@ -177,7 +180,9 @@ impl RdpServerDisplayUpdates for DisplayUpdates { } info!("get_update +{x}+{y} {width}x{height}"); - let stride = NonZeroUsize::from(width).checked_mul(NonZero::new(4).unwrap()).unwrap(); + let stride = NonZeroUsize::from(width) + .checked_mul(NonZeroUsize::new(4).expect("never zero")) + .expect("never overflow"); let bitmap = BitmapUpdate { x, y, @@ -187,7 +192,7 @@ impl RdpServerDisplayUpdates for DisplayUpdates { data: data.into(), stride, }; - Some(DisplayUpdate::Bitmap(bitmap)) + Ok(Some(DisplayUpdate::Bitmap(bitmap))) } } @@ -230,8 +235,7 @@ struct StubSoundServerFactory { impl ServerEventSender for StubSoundServerFactory { fn set_sender(&mut self, sender: UnboundedSender) { - let mut inner = self.inner.lock().unwrap(); - + let mut inner = self.inner.lock().expect("poisoned"); inner.ev_sender = Some(sender); } } @@ -337,7 +341,7 @@ impl RdpsndServerHandler for SndHandler { wave.into_iter().flat_map(|value| value.to_le_bytes()).collect() }; - let inner = inner.lock().unwrap(); + let inner = inner.lock().expect("poisoned"); if let Some(sender) = inner.ev_sender.as_ref() { let _ = sender.send(ServerEvent::Rdpsnd(RdpsndServerMessage::Wave(data, ts))); } diff --git a/ffi/build.rs b/ffi/build.rs index 4f2cacea74..98bb256791 100644 --- a/ffi/build.rs +++ b/ffi/build.rs @@ -4,7 +4,7 @@ use other::main_stub; use win::main_stub; fn main() { - main_stub(); + main_stub() } #[cfg(target_os = "windows")] @@ -19,7 +19,8 @@ mod win { let company_name = "Devolutions Inc."; let legal_copyright = format!("Copyright 2019-2024 {company_name}"); - let mut cargo_version = env::var("CARGO_PKG_VERSION").unwrap(); + let mut cargo_version = + env::var("CARGO_PKG_VERSION").expect("failed to fetch `CARGO_PKG_VERSION` environment variable"); cargo_version.push_str(".0"); let version_number = cargo_version; @@ -74,14 +75,15 @@ END } pub(crate) fn main_stub() { - let out_dir = env::var("OUT_DIR").unwrap(); + let out_dir = env::var("OUT_DIR").expect("failed to fetch `OUT_DIR` environment variable"); let version_rc_file = format!("{out_dir}/version.rc"); let version_rc_data = generate_version_rc(); - let mut file = File::create(&version_rc_file).expect("cannot create version.rc file"); - file.write_all(version_rc_data.as_bytes()).unwrap(); + let mut file = File::create(&version_rc_file).expect("failed to create version.rc file"); + file.write_all(version_rc_data.as_bytes()) + .expect("failed to write data to version.rc file"); embed_resource::compile(&version_rc_file, embed_resource::NONE) .manifest_required() - .unwrap(); + .expect("failed to compiler the Windows resource file"); } } diff --git a/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs b/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs index ce67b0c154..a57935b929 100644 --- a/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs +++ b/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs @@ -1,12 +1,13 @@ -use ironrdp::svc::SvcMessage; use std::sync::mpsc; +use ironrdp::svc::SvcMessage; + #[diplomat::bridge] pub mod ffi { - use crate::error::ffi::IronRdpError; use std::sync::mpsc; use super::{DvcPipeProxyMessageInner, DvcPipeProxyMessageQueueInner}; + use crate::error::ffi::IronRdpError; #[diplomat::opaque] pub struct DvcPipeProxyMessage(pub DvcPipeProxyMessageInner); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e7b4e6f5b2..cf4220282f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,6 +1,5 @@ #![allow(clippy::print_stdout)] #![allow(clippy::print_stderr)] -#![allow(clippy::unwrap_used)] #![allow(unreachable_pub)] mod macros; @@ -135,7 +134,7 @@ fn project_root() -> PathBuf { Path::new(&env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(1) - .unwrap() + .expect("failed to retrieve project root path") .to_path_buf() } From fe47ced857ddc6f15d396108119dac7f4839a986 Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:53:10 +0300 Subject: [PATCH 027/325] chore: add `pub_without_shorthand` clippy style and readability lint (#977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [pub_without_shorthand](https://rust-lang.github.io/rust-clippy/master/index.html#/pub_without_shorthand): > Checks for usage of pub() without in. > Note: As you cannot write a module’s path in pub(), this will only trigger on pub(super) and the like. --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 15b668e9e4..84cfe42ee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,6 +120,7 @@ unused_result_ok = "warn" semicolon_outside_block = "warn" # With semicolon-outside-block-ignore-multiline = true clone_on_ref_ptr = "warn" cloned_instead_of_copied = "warn" +pub_without_shorthand = "warn" trait_duplication_in_bounds = "warn" type_repetition_in_bounds = "warn" checked_conversions = "warn" From e5042a7d81b864e78ccf19d6b358d94458f951d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Thu, 18 Sep 2025 08:20:15 -0700 Subject: [PATCH 028/325] build(deps): replace opus by opus2 (#985) opus is unmaintained and ponits to a 4-year old commit of the opus C library. This does not compile anymore on our CI, because their CMakeList.txt requires an older version of cmake that is not available in the runners we use. opus2 is a fork that points to a more recent version of it. --- Cargo.lock | 35 ++++++++++++------------ crates/ironrdp-rdpsnd-native/Cargo.toml | 4 +-- crates/ironrdp-rdpsnd-native/src/cpal.rs | 6 ++-- crates/ironrdp/Cargo.toml | 2 +- crates/ironrdp/examples/server.rs | 8 +++--- crates/ironrdp/src/lib.rs | 2 +- 6 files changed, 28 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a2bb8bc9a5..a40b6e86c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,17 +337,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "audiopus_sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" -dependencies = [ - "cmake", - "log", - "pkg-config", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -2347,7 +2336,7 @@ dependencies = [ "ironrdp-server", "ironrdp-session", "ironrdp-svc", - "opus", + "opus2", "pico-args", "rand 0.9.2", "sspi", @@ -2717,7 +2706,7 @@ dependencies = [ "bytemuck", "cpal", "ironrdp-rdpsnd", - "opus", + "opus2", "tracing", "tracing-subscriber", ] @@ -3009,6 +2998,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libopus_sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e01ac33533ea26ecd6c9479ebc44833b88b0d8e9ab046b47cad3562d29ee33" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + [[package]] name = "libredox" version = "0.1.9" @@ -3722,13 +3722,12 @@ dependencies = [ ] [[package]] -name = "opus" -version = "0.3.0" +name = "opus2" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6526409b274a7e98e55ff59d96aafd38e6cd34d46b7dbbc32ce126dffcd75e8e" +checksum = "a8e79f6e5198dfc9ec913fd4ddc8b53b87263d59c500b989f8449bd566552ce3" dependencies = [ - "audiopus_sys", - "libc", + "libopus_sys", ] [[package]] diff --git a/crates/ironrdp-rdpsnd-native/Cargo.toml b/crates/ironrdp-rdpsnd-native/Cargo.toml index 95647c66c0..444b6a8207 100644 --- a/crates/ironrdp-rdpsnd-native/Cargo.toml +++ b/crates/ironrdp-rdpsnd-native/Cargo.toml @@ -16,14 +16,14 @@ test = false [features] default = ["opus"] -opus = ["dep:opus", "dep:bytemuck"] +opus = ["dep:opus2", "dep:bytemuck"] [dependencies] anyhow = "1" bytemuck = { version = "1.23", optional = true } cpal = "0.16" ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.6" } # public -opus = { version = "0.3", optional = true } +opus2 = { version = "0.3", optional = true, features = ["bundled"] } tracing = { version = "0.1", features = ["log"] } [dev-dependencies] diff --git a/crates/ironrdp-rdpsnd-native/src/cpal.rs b/crates/ironrdp-rdpsnd-native/src/cpal.rs index c3bddb4cfa..8e0cc22989 100644 --- a/crates/ironrdp-rdpsnd-native/src/cpal.rs +++ b/crates/ironrdp-rdpsnd-native/src/cpal.rs @@ -144,12 +144,12 @@ impl DecodeStream { #[cfg(feature = "opus")] WaveFormat::OPUS => { let chan = match rx_format.n_channels { - 1 => opus::Channels::Mono, - 2 => opus::Channels::Stereo, + 1 => opus2::Channels::Mono, + 2 => opus2::Channels::Stereo, _ => bail!("unsupported #channels for Opus"), }; let (dec_tx, dec_rx) = mpsc::channel(); - let mut dec = opus::Decoder::new(rx_format.n_samples_per_sec, chan)?; + let mut dec = opus2::Decoder::new(rx_format.n_samples_per_sec, chan)?; dec_thread = Some(thread::spawn(move || { while let Ok(pkt) = rx.recv() { let nb_samples = match dec.get_nb_samples(&pkt) { diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index 839630fcd0..90a59c749a 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -66,7 +66,7 @@ tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio-rustls = "0.26" rand = "0.9" -opus = "0.3" +opus2 = "0.3" [package.metadata.docs.rs] cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] diff --git a/crates/ironrdp/examples/server.rs b/crates/ironrdp/examples/server.rs index dde826ba1a..35e8175dec 100644 --- a/crates/ironrdp/examples/server.rs +++ b/crates/ironrdp/examples/server.rs @@ -300,16 +300,16 @@ impl RdpsndServerHandler for SndHandler { let fmt = client_format.formats[usize::from(nfmt)].clone(); let mut opus_enc = if fmt.format == WaveFormat::OPUS { - let n_channels: opus::Channels = match fmt.n_channels { - 1 => opus::Channels::Mono, - 2 => opus::Channels::Stereo, + let n_channels: opus2::Channels = match fmt.n_channels { + 1 => opus2::Channels::Mono, + 2 => opus2::Channels::Stereo, n => { warn!("Invalid OPUS channels: {}", n); return Some(0); } }; - match opus::Encoder::new(fmt.n_samples_per_sec, n_channels, opus::Application::Audio) { + match opus2::Encoder::new(fmt.n_samples_per_sec, n_channels, opus2::Application::Audio) { Ok(enc) => Some(enc), Err(err) => { warn!("Failed to create OPUS encoder: {}", err); diff --git a/crates/ironrdp/src/lib.rs b/crates/ironrdp/src/lib.rs index eb920a3a9c..4c34571c71 100644 --- a/crates/ironrdp/src/lib.rs +++ b/crates/ironrdp/src/lib.rs @@ -4,7 +4,7 @@ #[cfg(test)] use { - anyhow as _, async_trait as _, image as _, ironrdp_blocking as _, ironrdp_cliprdr_native as _, opus as _, + anyhow as _, async_trait as _, image as _, ironrdp_blocking as _, ironrdp_cliprdr_native as _, opus2 as _, pico_args as _, rand as _, sspi as _, tokio_rustls as _, tracing as _, tracing_subscriber as _, x509_cert as _, }; From e6421b509cc9143ff1a6872a0a012bcaeaae240d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:36:15 -0400 Subject: [PATCH 029/325] build(deps): bump the patch group across 1 directory with 5 updates (#990) --- Cargo.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a40b6e86c2..5a7b60ff81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2057,9 +2057,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64", "bytes", @@ -2944,9 +2944,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.78" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -4832,9 +4832,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" @@ -5487,9 +5487,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ "rustls", "tokio", @@ -5938,9 +5938,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", @@ -5951,9 +5951,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -5965,9 +5965,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.51" +version = "0.4.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", @@ -5978,9 +5978,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5988,9 +5988,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -6001,9 +6001,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] @@ -6119,9 +6119,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.78" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" dependencies = [ "js-sys", "wasm-bindgen", From 5f52a44b840dd71eae6a355be00f1c4c671b3b58 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Thu, 18 Sep 2025 19:40:23 +0000 Subject: [PATCH 030/325] fix(dvc-pipe-proxy): change dvc proxy pipe mode from Message to Byte on Windows (#986) --- crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs b/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs index 4c2b9c2764..7d40ac3251 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs @@ -24,7 +24,7 @@ impl OsPipe for WindowsPipe { .max_instances(2) .in_buffer_size(PIPE_BUFFER_SIZE) .out_buffer_size(PIPE_BUFFER_SIZE) - .pipe_mode(named_pipe::PipeMode::Message) + .pipe_mode(named_pipe::PipeMode::Byte) .create(pipe_name) .map_err(DvcPipeProxyError::Io)?; From 3182a018e2972eb77c52ea248387c96a9eb6a6a6 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Thu, 18 Sep 2025 01:25:44 +0300 Subject: [PATCH 031/325] fix(dvc-pipe-proxy): add blocking logic for sending dvc pipe messages --- crates/ironrdp-dvc-pipe-proxy/src/proxy.rs | 28 +++++++--- crates/ironrdp-dvc-pipe-proxy/src/worker.rs | 59 +++++++++++++++++---- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs b/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs index cc4966818b..d6b5f44ff2 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{mpsc, Arc}; use ironrdp_core::impl_as_any; use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; @@ -11,7 +11,7 @@ use crate::worker::{run_worker, OnWriteDvcMessage, WorkerCtx}; const IO_MPSC_CHANNEL_SIZE: usize = 100; struct WorkerControlCtx { - to_pipe_tx: tokio::sync::mpsc::Sender>, + to_pipe_tx: mpsc::SyncSender>, abort_event: Arc, } @@ -56,7 +56,7 @@ impl DvcProcessor for DvcNamedPipeProxy { .take() .expect("DvcProcessor::start called multiple times"); - let (to_pipe_tx, to_pipe_rx) = tokio::sync::mpsc::channel(IO_MPSC_CHANNEL_SIZE); + let (to_pipe_tx, to_pipe_rx) = mpsc::sync_channel(IO_MPSC_CHANNEL_SIZE); let abort_event = Arc::new(tokio::sync::Notify::new()); @@ -85,12 +85,24 @@ impl DvcProcessor for DvcNamedPipeProxy { fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { if let Some(worker) = &self.worker { - if let Err(error) = worker.to_pipe_tx.try_send(payload.to_vec()) { + // TODO(@pacmancoder): Whatever buffer size we use here, we will hit buffer limit + // eventually and fail if we are not send it in a blocking manner. + // + // Architecturally, blocking whole IronRDP/async runitme is not ideal (even if we know + // that proxy worker is running on a separate thread and there should be no risk of + // deadlock). + // + // Therefore it is only a temporary solution until we have a better design for DVC + // channels which could block. However its the only way to stop the DVC message flow + // from the host. + // + // During testing, blocking here don't seem to affect performance in any noticeable + // way - there is no visible main RDP functionality slowdown during large IO + // stream transfer. + let result = worker.to_pipe_tx.send(payload.to_vec()); + if let Err(error) = result { match error { - tokio::sync::mpsc::error::TrySendError::Full(_) => { - return Err(pdu_other_err!("DVC pipe proxy channel is full")); - } - tokio::sync::mpsc::error::TrySendError::Closed(_) => { + mpsc::SendError(_) => { return Err(pdu_other_err!("DVC pipe proxy channel is closed")); } } diff --git a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs index 7b4dbf0b9b..f0c332efc0 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs @@ -1,9 +1,9 @@ -use std::sync::Arc; +use std::sync::{mpsc, Arc}; use ironrdp_dvc::encode_dvc_messages; use ironrdp_pdu::PduResult; use ironrdp_svc::{ChannelFlags, SvcMessage}; -use tokio::sync::{mpsc, Notify}; +use tokio::sync::Notify; use tracing::{error, info}; use crate::error::DvcPipeProxyError; @@ -62,7 +62,16 @@ enum NextWorkerState { Reconnect, } -async fn process_client(ctx: &mut WorkerCtx) -> Result { +struct BridgedWorkerCtx { + on_write_dvc: OnWriteDvcMessage, + to_pipe_rx: tokio::sync::mpsc::UnboundedReceiver>, + abort_event: Arc, + pipe_name: String, + channel_name: String, + channel_id: u32, +} + +async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result { let pipe_name = &ctx.pipe_name; let channel_name = &ctx.channel_name; @@ -132,21 +141,53 @@ async fn process_client(ctx: &mut WorkerCtx) -> Result(mut ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { +async fn worker(ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { + // Create a bridge between std::sync::mpsc and tokio for async compatibility. + // It is fine to use unbounded channel here because we are using it only to + // forward data from a bounded channel (with size IO_MPSC_CHANNEL_SIZE), + // so we will never have unbounded memory growth. + let (async_tx, async_rx) = tokio::sync::mpsc::unbounded_channel(); + + let WorkerCtx { + on_write_dvc, + to_pipe_rx: std_rx, + abort_event, + pipe_name, + channel_name, + channel_id, + } = ctx; + + // Spawn a thread to bridge std::sync::mpsc to tokio::sync::mpsc. + std::thread::spawn(move || { + while let Ok(data) = std_rx.recv() { + if async_tx.send(data).is_err() { + break; // Receiver dropped + } + } + }); + + let mut bridged_ctx = BridgedWorkerCtx { + on_write_dvc, + to_pipe_rx: async_rx, + abort_event, + pipe_name, + channel_name, + channel_id, + }; loop { - match process_client::

(&mut ctx).await? { + match process_client::

(&mut bridged_ctx).await? { NextWorkerState::Abort => { info!( - channel_name = %ctx.channel_name, - pipe_name = %ctx.pipe_name, + channel_name = %bridged_ctx.channel_name, + pipe_name = %bridged_ctx.pipe_name, "Aborting DVC proxy worker thread." ); break; } NextWorkerState::Reconnect => { info!( - channel_name = %ctx.channel_name, - pipe_name = %ctx.pipe_name, + channel_name = %bridged_ctx.channel_name, + pipe_name = %bridged_ctx.pipe_name, "Reconnecting to DVC pipe..." ); continue; From 6c0014d5b318af361c06a0ae86e24388d7e7dcab Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:05:20 +0300 Subject: [PATCH 032/325] fix(web)!: rework error handling (#975) Improves the error handling in _iron-remote-desktop_ by replacing the session events with throwing errors for terminated and error events and callbacks for warnings and the clipboard remote update event. --- .../src/enums/SessionEventType.ts | 9 - .../interfaces/{session-event.ts => Error.ts} | 9 +- .../src/interfaces/NewSessionInfo.ts | 2 + .../src/interfaces/UserInteraction.ts | 9 +- web-client/iron-remote-desktop/src/main.ts | 3 +- .../src/services/PublicAPI.ts | 17 +- .../src/services/clipboard.service.ts | 196 +++++++----------- .../src/services/remote-desktop.service.ts | 79 +++---- .../src/lib/login/login.svelte | 58 +++--- .../src/lib/popup-screen/popup-screen.svelte | 18 +- .../lib/remote-screen/remote-screen.svelte | 10 +- 11 files changed, 162 insertions(+), 248 deletions(-) delete mode 100644 web-client/iron-remote-desktop/src/enums/SessionEventType.ts rename web-client/iron-remote-desktop/src/interfaces/{session-event.ts => Error.ts} (56%) diff --git a/web-client/iron-remote-desktop/src/enums/SessionEventType.ts b/web-client/iron-remote-desktop/src/enums/SessionEventType.ts deleted file mode 100644 index 5ade51a0e9..0000000000 --- a/web-client/iron-remote-desktop/src/enums/SessionEventType.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum SessionEventType { - STARTED, - TERMINATED, - ERROR, - WARNING, - - // Clipboard events - CLIPBOARD_REMOTE_UPDATE, -} diff --git a/web-client/iron-remote-desktop/src/interfaces/session-event.ts b/web-client/iron-remote-desktop/src/interfaces/Error.ts similarity index 56% rename from web-client/iron-remote-desktop/src/interfaces/session-event.ts rename to web-client/iron-remote-desktop/src/interfaces/Error.ts index e79aebef52..093593e4eb 100644 --- a/web-client/iron-remote-desktop/src/interfaces/session-event.ts +++ b/web-client/iron-remote-desktop/src/interfaces/Error.ts @@ -1,6 +1,4 @@ -import type { SessionEventType } from '../enums/SessionEventType'; - -export enum IronErrorKind { +export enum IronErrorKind { General = 0, WrongPassword = 1, LogonFailure = 2, @@ -14,8 +12,3 @@ export interface IronError { backtrace: () => string; kind: () => IronErrorKind; } - -export interface SessionEvent { - type: SessionEventType; - data: IronError | string; -} diff --git a/web-client/iron-remote-desktop/src/interfaces/NewSessionInfo.ts b/web-client/iron-remote-desktop/src/interfaces/NewSessionInfo.ts index add2ed7457..afcd59cbbc 100644 --- a/web-client/iron-remote-desktop/src/interfaces/NewSessionInfo.ts +++ b/web-client/iron-remote-desktop/src/interfaces/NewSessionInfo.ts @@ -1,7 +1,9 @@ import type { DesktopSize } from './DesktopSize'; +import type { SessionTerminationInfo } from './SessionTerminationInfo'; export interface NewSessionInfo { sessionId: number; websocketPort: number; initialDesktopSize: DesktopSize; + run: () => Promise; } diff --git a/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts b/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts index fd1c4ec022..f11d4f466a 100644 --- a/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts +++ b/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts @@ -1,6 +1,5 @@ import type { ScreenScale } from '../enums/ScreenScale'; import type { NewSessionInfo } from './NewSessionInfo'; -import type { SessionEvent } from './session-event'; import { ConfigBuilder } from '../services/ConfigBuilder'; import type { Config } from '../services/Config'; import type { Extension } from './Extension'; @@ -25,7 +24,9 @@ export interface UserInteraction { setCursorStyleOverride(style: string | null): void; - onSessionEvent(callback: Callback): void; + onWarningCallback(callback: Callback): void; + + onClipboardRemoteUpdateCallback(callback: Callback): void; resize(width: number, height: number, scale?: number): void; @@ -33,9 +34,9 @@ export interface UserInteraction { setEnableAutoClipboard(enable: boolean): void; - saveRemoteClipboardData(): Promise; + saveRemoteClipboardData(): Promise; - sendClipboardData(): Promise; + sendClipboardData(): Promise; invokeExtension(ext: Extension): void; } diff --git a/web-client/iron-remote-desktop/src/main.ts b/web-client/iron-remote-desktop/src/main.ts index 0ee6f0224c..bf834c6347 100644 --- a/web-client/iron-remote-desktop/src/main.ts +++ b/web-client/iron-remote-desktop/src/main.ts @@ -1,8 +1,7 @@ export * as default from './iron-remote-desktop.svelte'; export type { ResizeEvent } from './interfaces/ResizeEvent'; export type { NewSessionInfo } from './interfaces/NewSessionInfo'; -export type { SessionEvent, IronError, IronErrorKind } from './interfaces/session-event'; -export type { SessionEventType } from './enums/SessionEventType'; +export type { IronError, IronErrorKind } from './interfaces/Error'; export type { SessionTerminationInfo } from './interfaces/SessionTerminationInfo'; export type { ClipboardData } from './interfaces/ClipboardData'; export type { ClipboardItem } from './interfaces/ClipboardItem'; diff --git a/web-client/iron-remote-desktop/src/services/PublicAPI.ts b/web-client/iron-remote-desktop/src/services/PublicAPI.ts index c425caf24d..bdff7a88cf 100644 --- a/web-client/iron-remote-desktop/src/services/PublicAPI.ts +++ b/web-client/iron-remote-desktop/src/services/PublicAPI.ts @@ -68,11 +68,19 @@ export class PublicAPI { this.remoteDesktopService.setEnableAutoClipboard(enable); } - private async saveRemoteClipboardData(): Promise { + private setOnWarningCallback(callback: (data: string) => void) { + this.remoteDesktopService.setOnWarningCallback(callback); + } + + private setOnClipboardRemoteUpdateCallback(callback: () => void) { + this.remoteDesktopService.setOnClipboardRemoteUpdate(callback); + } + + private async saveRemoteClipboardData(): Promise { return await this.clipboardService.saveRemoteClipboardData(); } - private async sendClipboardData(): Promise { + private async sendClipboardData(): Promise { return await this.clipboardService.sendClipboardData(); } @@ -85,10 +93,9 @@ export class PublicAPI { setVisibility: this.setVisibility.bind(this), configBuilder: this.configBuilder.bind(this), connect: this.connect.bind(this), + onWarningCallback: this.setOnWarningCallback.bind(this), + onClipboardRemoteUpdateCallback: this.setOnClipboardRemoteUpdateCallback.bind(this), setScale: this.setScale.bind(this), - onSessionEvent: (callback) => { - this.remoteDesktopService.sessionEventObservable.subscribe(callback); - }, ctrlAltDel: this.ctrlAltDel.bind(this), metaKey: this.metaKey.bind(this), shutdown: this.shutdown.bind(this), diff --git a/web-client/iron-remote-desktop/src/services/clipboard.service.ts b/web-client/iron-remote-desktop/src/services/clipboard.service.ts index 0c1bb87886..ca85a8a5c9 100644 --- a/web-client/iron-remote-desktop/src/services/clipboard.service.ts +++ b/web-client/iron-remote-desktop/src/services/clipboard.service.ts @@ -4,11 +4,19 @@ import { get } from 'svelte/store'; import type { ClipboardData } from '../interfaces/ClipboardData'; import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; import { runWhenFocusedQueue } from '../lib/stores/runWhenFocusedStore'; -import { SessionEventType } from '../enums/SessionEventType'; import { ClipboardApiSupported } from '../enums/ClipboardApiSupported'; +import { IronErrorKind } from '../interfaces/Error'; const CLIPBOARD_MONITORING_INTERVAL_MS = 100; +// Helper function to conveniently throw an `IronError`. +function throwIronError(message: string): never { + throw { + kind: () => IronErrorKind.General, + backtrace: () => message, + }; +} + export class ClipboardService { private remoteDesktopService: RemoteDesktopService; private module: RemoteDesktopModule; @@ -29,10 +37,7 @@ export class ClipboardService { initClipboard() { // Clipboard API is available only in secure contexts (HTTPS). if (!window.isSecureContext) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.WARNING, - data: 'Clipboard is available only in secure contexts (HTTPS).', - }); + this.remoteDesktopService.emitWarningEvent('Clipboard is available only in secure contexts (HTTPS).'); return; } @@ -42,26 +47,23 @@ export class ClipboardService { this.ClipboardApiSupported = ClipboardApiSupported.Full; } else if (navigator.clipboard.readText != undefined) { this.ClipboardApiSupported = ClipboardApiSupported.TextOnly; - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.WARNING, - data: 'Clipboard is limited to text-only data types due to an outdated browser version!', - }); + this.remoteDesktopService.emitWarningEvent( + 'Clipboard is limited to text-only data types due to an outdated browser version!', + ); } else if (navigator.clipboard.writeText != undefined) { this.ClipboardApiSupported = ClipboardApiSupported.TextOnlyServerOnly; - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.WARNING, - data: 'Clipboard reading is not supported and writing is limited to text-only data types due to an outdated browser version!', - }); + this.remoteDesktopService.emitWarningEvent( + 'Clipboard reading is not supported and writing is limited to text-only data types due to an outdated browser version!', + ); } } // The basic Clipboard API is widely supported in modern browsers, // so this condition should never be true in practice. if (this.ClipboardApiSupported === ClipboardApiSupported.None) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.WARNING, - data: 'Clipboard is not supported due to an outdated browser version!', - }); + this.remoteDesktopService.emitWarningEvent( + 'Clipboard is not supported due to an outdated browser version!', + ); return; } @@ -84,17 +86,13 @@ export class ClipboardService { // Copies clipboard content received from the server to the local clipboard. // Returns the result of the operation. On failure, it additionally raises an error session event. - async saveRemoteClipboardData(): Promise { + async saveRemoteClipboardData(): Promise { if (this.ClipboardApiSupported !== ClipboardApiSupported.Full) { return await this.ffSaveRemoteClipboardData(); } if (this.clipboardDataToSave == null) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'The server did not send the clipboard data.', - }); - return false; + throwIronError('The server did not send the clipboard data.'); } try { @@ -103,74 +101,53 @@ export class ClipboardService { await navigator.clipboard.write([clipboard_item]); this.clipboardDataToSave = null; - return true; } catch (err) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'Failed to write to the clipboard: ' + err, - }); - return false; + throwIronError('Failed to write to the clipboard: ' + err); } } // Sends local clipboard's content to the server. // Returns the result of the operation. On failure, it additionally raises an error session event. - async sendClipboardData(): Promise { + async sendClipboardData(): Promise { if (this.ClipboardApiSupported !== ClipboardApiSupported.Full) { return await this.ffSendClipboardData(); } - try { - const value = await navigator.clipboard.read(); - - // Clipboard is empty - if (value.length == 0) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'The clipboard has no data.', - }); - return false; - } + const value = await navigator.clipboard.read().catch((err) => { + throwIronError('Failed to read from the clipboard: ' + err); + }); - // We only support one item at a time - const item = value[0]; + // Clipboard is empty + if (value.length == 0) { + throwIronError('The clipboard has no data.'); + } - if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { - // Unsupported types - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'The clipboard has no data of supported type (text or image).', - }); - return false; - } + // We only support one item at a time + const item = value[0]; - const clipboardData = new this.module.ClipboardData(); + if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { + // Unsupported types + throwIronError('The clipboard has no data of supported type (text or image).'); + } - for (const kind of item.types) { - // Get blob - const blobIsString = kind.startsWith('text/'); - const blob = await item.getType(kind); + const clipboardData = new this.module.ClipboardData(); - if (blobIsString) { - clipboardData.addText(kind, await blob.text()); - } else { - clipboardData.addBinary(kind, new Uint8Array(await blob.arrayBuffer())); - } - } + for (const kind of item.types) { + // Get blob + const blobIsString = kind.startsWith('text/'); + const blob = await item.getType(kind); - if (!clipboardData.isEmpty()) { - this.lastSentClipboardData = clipboardData; - // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. - await this.remoteDesktopService.onClipboardChanged(clipboardData); + if (blobIsString) { + clipboardData.addText(kind, await blob.text()); + } else { + clipboardData.addBinary(kind, new Uint8Array(await blob.arrayBuffer())); } + } - return true; - } catch (err) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'Failed to read from the clipboard: ' + err, - }); - return false; + if (!clipboardData.isEmpty()) { + this.lastSentClipboardData = clipboardData; + // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. + await this.remoteDesktopService.onClipboardChanged(clipboardData); } } @@ -223,10 +200,7 @@ export class ClipboardService { // This callback is required to update client clipboard state when remote side has changed. private onRemoteClipboardChangedManualMode(data: ClipboardData) { this.clipboardDataToSave = data; - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.CLIPBOARD_REMOTE_UPDATE, - data: '', - }); + this.remoteDesktopService.emitClipboardRemoteUpdateEvent(); } // This callback is required to update client clipboard state when remote side has changed. @@ -244,7 +218,7 @@ export class ClipboardService { } // Called periodically to monitor clipboard changes - private async onMonitorClipboard() { + private async onMonitorClipboard(): Promise { try { if (!document.hasFocus()) { return; @@ -382,35 +356,23 @@ export class ClipboardService { if (value === '') return; this.ffClipboardDataToSave = value; - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.CLIPBOARD_REMOTE_UPDATE, - data: '', - }); + this.remoteDesktopService.emitClipboardRemoteUpdateEvent(); } // Firefox specific function. We are using text-only clipboard API here. // // Copies clipboard content received from the server to the local clipboard. // Returns the result of the operation. On failure, it additionally raises an error session event. - private async ffSaveRemoteClipboardData(): Promise { + private async ffSaveRemoteClipboardData(): Promise { if (this.ffClipboardDataToSave == null) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'The server did not send the clipboard data.', - }); - return false; + throwIronError('The server did not send the clipboard data.'); } try { await navigator.clipboard.writeText(this.ffClipboardDataToSave); this.ffClipboardDataToSave = null; - return true; } catch (err) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'Failed to write to the clipboard: ' + err, - }); - return false; + throwIronError('Failed to write to the clipboard: ' + err); } } @@ -418,43 +380,27 @@ export class ClipboardService { // // Sends local clipboard's content to the server. // Returns the result of the operation. On failure, it additionally raises an error session event. - private async ffSendClipboardData(): Promise { + private async ffSendClipboardData(): Promise { if (this.ClipboardApiSupported !== ClipboardApiSupported.TextOnly) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'The browser does not support clipboard read.', - }); - return false; + throwIronError('The browser does not support clipboard read.'); } - try { - const value = await navigator.clipboard.readText(); - - // Clipboard is empty - if (value.length == 0) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'The clipboard has no data.', - }); - return false; - } + const value = await navigator.clipboard.readText().catch((err) => { + throwIronError('Failed to read from the clipboard: ' + err); + }); - const clipboardData = new this.module.ClipboardData(); - clipboardData.addText('text/plain', value); + // Clipboard is empty + if (value.length == 0) { + throwIronError('The clipboard has no data.'); + } - if (!clipboardData.isEmpty()) { - this.lastSentClipboardData = clipboardData; - // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. - await this.remoteDesktopService.onClipboardChanged(clipboardData); - } + const clipboardData = new this.module.ClipboardData(); + clipboardData.addText('text/plain', value); - return true; - } catch (err) { - this.remoteDesktopService.raiseSessionEvent({ - type: SessionEventType.ERROR, - data: 'Failed to read from the clipboard: ' + err, - }); - return false; + if (!clipboardData.isEmpty()) { + this.lastSentClipboardData = clipboardData; + // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. + await this.remoteDesktopService.onClipboardChanged(clipboardData); } } } diff --git a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts index 710fb81955..4c50ea9f5e 100644 --- a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts +++ b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts @@ -2,13 +2,11 @@ import { loggingService } from './logging.service'; import { scanCode } from '../lib/scancodes'; import { ModifierKey } from '../enums/ModifierKey'; import { LockKey } from '../enums/LockKey'; -import { SessionEventType } from '../enums/SessionEventType'; import type { NewSessionInfo } from '../interfaces/NewSessionInfo'; import { SpecialCombination } from '../enums/SpecialCombination'; import type { ResizeEvent } from '../interfaces/ResizeEvent'; import { ScreenScale } from '../enums/ScreenScale'; import type { MousePosition } from '../interfaces/MousePosition'; -import type { IronError, IronErrorKind, SessionEvent } from '../interfaces/session-event'; import type { ClipboardData } from '../interfaces/ClipboardData'; import type { Session } from '../interfaces/Session'; import { RotationUnit } from '../interfaces/DeviceEvent'; @@ -24,6 +22,8 @@ type OnRemoteClipboardChanged = (data: ClipboardData) => void; type OnRemoteReceivedFormatsList = () => void; type OnForceClipboardUpdate = () => void; type OnCanvasResized = () => void; +type OnWarning = (data: string) => void; +type OnClipboardRemoteUpdate = () => void; export class RemoteDesktopService { private module: RemoteDesktopModule; @@ -34,6 +34,8 @@ export class RemoteDesktopService { private onRemoteReceivedFormatList?: OnRemoteReceivedFormatsList; private onForceClipboardUpdate?: OnForceClipboardUpdate; private onCanvasResized?: OnCanvasResized; + private onWarningCallback?: OnWarning; + private onClipboardRemoteUpdate?: OnClipboardRemoteUpdate; private cursorHasOverride: boolean = false; private lastCursorStyle: string = 'default'; private enableClipboard: boolean = true; @@ -46,7 +48,6 @@ export class RemoteDesktopService { mousePositionObservable: Observable = new Observable(); changeVisibilityObservable: Observable = new Observable(); - sessionEventObservable: Observable = new Observable(); scaleObservable: Observable = new Observable(); dynamicResizeObservable: Observable<{ width: number; height: number }> = new Observable(); @@ -89,6 +90,16 @@ export class RemoteDesktopService { this.onCanvasResized = callback; } + /// Callback which is called when the warning event is emitted. + setOnWarningCallback(callback: OnWarning) { + this.onWarningCallback = callback; + } + + /// Callback which is called when the clipboard remote update event is emitted. + setOnClipboardRemoteUpdate(callback: OnClipboardRemoteUpdate) { + this.onClipboardRemoteUpdate = callback; + } + mouseIn(event: MouseEvent) { this.syncModifier(event); } @@ -160,21 +171,7 @@ export class RemoteDesktopService { ); } - const session = await sessionBuilder.connect().catch((err: IronError) => { - this.raiseSessionEvent({ - type: SessionEventType.TERMINATED, - data: { - backtrace: () => err.backtrace(), - kind: () => err.kind() as number as IronErrorKind, - }, - }); - // The client must ignore this error and use session events for error handling. - throw new Error(); - }); - - this.run(session); - - loggingService.info('Session started.'); + const session = await sessionBuilder.connect(); this.session = session; @@ -182,38 +179,24 @@ export class RemoteDesktopService { desktopSize: session.desktopSize(), sessionId: 0, }); - this.raiseSessionEvent({ - type: SessionEventType.STARTED, - data: 'Session started', - }); + + const run = async (): Promise => { + try { + loggingService.info('Starting the session.'); + return await session.run(); + } finally { + this.setVisibility(false); + } + }; return { sessionId: 0, initialDesktopSize: session.desktopSize(), websocketPort: 0, + run, }; } - run(session: Session) { - session - .run() - .then((terminationInfo: SessionTerminationInfo) => { - this.setVisibility(false); - this.raiseSessionEvent({ - type: SessionEventType.TERMINATED, - data: 'Session was terminated: ' + terminationInfo.reason() + '.', - }); - }) - .catch((err: IronError) => { - this.setVisibility(false); - - this.raiseSessionEvent({ - type: SessionEventType.TERMINATED, - data: 'Session was terminated with an error: ' + err.backtrace() + '.', - }); - }); - } - sendSpecialCombination(specialCombination: SpecialCombination): void { switch (specialCombination) { case SpecialCombination.CTRL_ALT_DEL: @@ -248,6 +231,14 @@ export class RemoteDesktopService { ]); } + emitWarningEvent(data: string): void { + this.onWarningCallback?.(data); + } + + emitClipboardRemoteUpdateEvent(): void { + this.onClipboardRemoteUpdate?.(); + } + setVisibility(state: boolean) { this.changeVisibilityObservable.publish(state); } @@ -299,10 +290,6 @@ export class RemoteDesktopService { this.session?.invokeExtension(ext); } - raiseSessionEvent(event: SessionEvent) { - this.sessionEventObservable.publish(event); - } - private releaseAllInputs() { this.session?.releaseAllInputs(); } diff --git a/web-client/iron-svelte-client/src/lib/login/login.svelte b/web-client/iron-svelte-client/src/lib/login/login.svelte index 22e725cdba..78a823edfd 100644 --- a/web-client/iron-svelte-client/src/lib/login/login.svelte +++ b/web-client/iron-svelte-client/src/lib/login/login.svelte @@ -1,6 +1,6 @@ @@ -357,7 +373,6 @@ onmousedown={(event) => setMouseButtonState(event, true)} onmouseup={(event) => setMouseButtonState(event, false)} onmouseleave={(event) => { - setMouseButtonState(event, false); setMouseOut(event); }} onmouseenter={(event) => { diff --git a/web-client/iron-remote-desktop/src/services/mouseInput.test.ts b/web-client/iron-remote-desktop/src/services/mouseInput.test.ts new file mode 100644 index 0000000000..20720a8cca --- /dev/null +++ b/web-client/iron-remote-desktop/src/services/mouseInput.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { RemoteDesktopService } from './remote-desktop.service'; +import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; +import type { Session } from '../interfaces/Session'; + +/** + * Regression tests for the Firefox stuck right-click bug. + * + * Root cause: The RDP server received mouseButtonPressed(2) but never + * mouseButtonReleased(2) when the user right-clicked and then moved the + * cursor off the canvas before releasing. Two defects contributed: + * + * Defect 1 (iron-remote-desktop.svelte): onmouseleave sent a spurious + * mouseButtonReleased for a hardcoded button index instead of calling + * releaseAllInputs — fixed in the component. + * + * Defect 2 (remote-desktop.service.ts): mouseIn() did not reconcile the + * browser's event.buttons bitmask against the RDP session's assumed + * button state, so re-entering the canvas left stale "button held" + * state on the server — fixed by the mouseIn() implementation tested here. + * + * These tests also cover the mouseOut() path which must call releaseAllInputs. + */ + +// ── Helpers ────────────────────────────────────────────────────────────────── + +class MockInputTransaction { + addEvent = vi.fn(); +} + +function createMockModule(): RemoteDesktopModule { + return { + SessionBuilder: class {} as unknown as RemoteDesktopModule['SessionBuilder'], + DesktopSize: class {} as unknown as RemoteDesktopModule['DesktopSize'], + InputTransaction: MockInputTransaction as unknown as RemoteDesktopModule['InputTransaction'], + ClipboardData: class {} as unknown as RemoteDesktopModule['ClipboardData'], + DeviceEvent: { + mouseButtonPressed: vi.fn((id: number) => ({ type: 'pressed', id })), + mouseButtonReleased: vi.fn((id: number) => ({ type: 'released', id })), + mouseMove: vi.fn(), + wheelRotations: vi.fn(), + keyPressed: vi.fn(), + keyReleased: vi.fn(), + unicodePressed: vi.fn(), + unicodeReleased: vi.fn(), + }, + }; +} + +function createMockSession(): Session { + return { + run: vi.fn().mockResolvedValue({ reason: () => 'test' }), + desktopSize: vi.fn().mockReturnValue({ width: 1920, height: 1080 }), + applyInputs: vi.fn(), + releaseAllInputs: vi.fn(), + synchronizeLockKeys: vi.fn(), + shutdown: vi.fn(), + onClipboardPaste: vi.fn(), + resize: vi.fn(), + supportsUnicodeKeyboardShortcuts: vi.fn().mockReturnValue(false), + invokeExtension: vi.fn(), + } as unknown as Session; +} + +// ── mouseOut ───────────────────────────────────────────────────────────────── + +describe('mouseOut', () => { + let service: RemoteDesktopService; + let session: Session; + + beforeEach(() => { + vi.clearAllMocks(); + service = new RemoteDesktopService(createMockModule()); + session = createMockSession(); + service.session = session; + }); + + it('calls releaseAllInputs on the session', () => { + service.mouseOut(new MouseEvent('mouseleave')); + expect(session.releaseAllInputs).toHaveBeenCalledTimes(1); + }); + + it('does not throw when there is no active session', () => { + service.session = undefined; + expect(() => service.mouseOut(new MouseEvent('mouseleave'))).not.toThrow(); + }); +}); + +// ── focusLost ───────────────────────────────────────────────────────────────── + +describe('focusLost', () => { + let service: RemoteDesktopService; + let session: Session; + + beforeEach(() => { + vi.clearAllMocks(); + service = new RemoteDesktopService(createMockModule()); + session = createMockSession(); + service.session = session; + }); + + it('calls releaseAllInputs on the session', () => { + service.focusLost(); + expect(session.releaseAllInputs).toHaveBeenCalledTimes(1); + }); + + it('does not throw when there is no active session', () => { + service.session = undefined; + expect(() => service.focusLost()).not.toThrow(); + }); +}); + +// ── mouseIn button reconciliation ───────────────────────────────────────────── + +describe('mouseIn button reconciliation', () => { + let service: RemoteDesktopService; + let mockModule: RemoteDesktopModule; + let session: Session; + + beforeEach(() => { + vi.clearAllMocks(); + mockModule = createMockModule(); + service = new RemoteDesktopService(mockModule); + session = createMockSession(); + service.session = session; + }); + + function mouseIn(buttons: number) { + service.mouseIn(new MouseEvent('mouseenter', { buttons })); + } + + it('releases all three buttons when no buttons are physically held (buttons=0)', () => { + mouseIn(0); + const released = vi.mocked(mockModule.DeviceEvent.mouseButtonReleased); + expect(released).toHaveBeenCalledWith(0); // left + expect(released).toHaveBeenCalledWith(2); // right + expect(released).toHaveBeenCalledWith(1); // middle + expect(released).toHaveBeenCalledTimes(3); + }); + + it('does not release the right button when it is physically held (buttons=2)', () => { + mouseIn(2); + const released = vi.mocked(mockModule.DeviceEvent.mouseButtonReleased); + expect(released).toHaveBeenCalledWith(0); // left released + expect(released).toHaveBeenCalledWith(1); // middle released + expect(released).not.toHaveBeenCalledWith(2); // right NOT released + expect(released).toHaveBeenCalledTimes(2); + }); + + it('does not release the left button when it is physically held (buttons=1)', () => { + mouseIn(1); + const released = vi.mocked(mockModule.DeviceEvent.mouseButtonReleased); + expect(released).toHaveBeenCalledWith(2); // right released + expect(released).toHaveBeenCalledWith(1); // middle released + expect(released).not.toHaveBeenCalledWith(0); // left NOT released + expect(released).toHaveBeenCalledTimes(2); + }); + + it('does not release the middle button when it is physically held (buttons=4)', () => { + mouseIn(4); + const released = vi.mocked(mockModule.DeviceEvent.mouseButtonReleased); + expect(released).toHaveBeenCalledWith(0); // left released + expect(released).toHaveBeenCalledWith(2); // right released + expect(released).not.toHaveBeenCalledWith(1); // middle NOT released + expect(released).toHaveBeenCalledTimes(2); + }); + + it('releases no buttons when all three are physically held (buttons=7)', () => { + mouseIn(7); + expect(vi.mocked(mockModule.DeviceEvent.mouseButtonReleased)).not.toHaveBeenCalled(); + }); + + it('does nothing when there is no active session (buttons=0)', () => { + service.session = undefined; + mouseIn(0); + expect(vi.mocked(mockModule.DeviceEvent.mouseButtonReleased)).not.toHaveBeenCalled(); + expect(session.applyInputs).not.toHaveBeenCalled(); + }); + + it('sends all releases in a single applyInputs transaction', () => { + mouseIn(0); // all three released → 1 batched transaction + expect(session.applyInputs).toHaveBeenCalledTimes(1); + }); + + it('sends no transactions when all buttons are held', () => { + mouseIn(7); + expect(session.applyInputs).not.toHaveBeenCalled(); + }); +}); diff --git a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts index d41a78125a..3ef5aa91cb 100644 --- a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts +++ b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts @@ -117,13 +117,31 @@ export class RemoteDesktopService { } mouseIn(event: MouseEvent) { + if (!this.session) return; this.syncModifier(event); + // Release any button the session thinks is held but the browser no longer reports, + // clearing stale state from buttons released outside the canvas (e.g. off-canvas mouseup). + const buttonsMap: [number, number][] = [ + [1, 0], // left button + [2, 2], // right button + [4, 1], // middle button + ]; + const releases = buttonsMap + .filter(([mask]) => (event.buttons & mask) === 0) + .map(([, buttonId]) => this.module.DeviceEvent.mouseButtonReleased(buttonId)); + if (releases.length > 0) { + this.doTransactionFromDeviceEvents(releases); + } } mouseOut(_event: MouseEvent) { this.releaseAllInputs(); } + focusLost() { + this.releaseAllInputs(); + } + sendKeyboardEvent(evt: KeyboardEvent) { this.sendKeyboard(evt); } From 67f3c635734d3cf23527fdc3fbff0dd8be0a3193 Mon Sep 17 00:00:00 2001 From: clintcan Date: Mon, 25 May 2026 14:26:09 +0800 Subject: [PATCH 234/325] fix(egfx): tolerate unknown capability versions instead of failing decode (#1298) --- crates/ironrdp-egfx/src/pdu/cmd.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-egfx/src/pdu/cmd.rs b/crates/ironrdp-egfx/src/pdu/cmd.rs index 305180052d..fd2360622c 100644 --- a/crates/ironrdp-egfx/src/pdu/cmd.rs +++ b/crates/ironrdp-egfx/src/pdu/cmd.rs @@ -1593,11 +1593,24 @@ impl<'de> Decode<'de> for CapabilitySet { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let version = CapabilityVersion::try_from(src.read_u32())?; + let version_raw = src.read_u32(); let data_length: usize = cast_length!("dataLength", src.read_u32())?; ensure_size!(in: src, size: data_length); let data = src.read_slice(data_length); + + // Tolerate capability versions this build doesn't recognize instead of + // failing the whole PDU. A strict error here aborts decoding of the + // entire CapabilitiesAdvertise during EGFX negotiation, which can + // prevent a connection from being established at all when a client + // advertises a capset version outside the set enumerated below + // (observed with the macOS "Windows App" / Microsoft Remote Desktop + // client). Preserving the raw bytes as `Unknown` lets negotiation + // complete so the server can still select a mutually supported version. + let Ok(version) = CapabilityVersion::try_from(version_raw) else { + return Ok(CapabilitySet::Unknown(data.to_vec())); + }; + let mut cur = ReadCursor::new(data); let size = match version { From 491b91fd2f33235e4b31dea5c4a215e67f734179 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 25 May 2026 06:13:47 -0500 Subject: [PATCH 235/325] fix(pdu)!: remove ironrdp-egfx duplicates from ironrdp-pdu (#1303) --- crates/ironrdp-pdu/src/lib.rs | 1 - .../dvc/gfx/graphics_messages/avc_messages.rs | 226 ---- .../vc/dvc/gfx/graphics_messages/client.rs | 179 --- .../rdp/vc/dvc/gfx/graphics_messages/mod.rs | 373 ------ .../vc/dvc/gfx/graphics_messages/server.rs | 1066 ----------------- crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/mod.rs | 313 ----- crates/ironrdp-pdu/src/rdp/vc/dvc/mod.rs | 1 - crates/ironrdp-pdu/src/rdp/vc/mod.rs | 2 - crates/ironrdp-testsuite-core/Cargo.toml | 2 +- crates/ironrdp-testsuite-core/src/gfx.rs | 10 +- .../src/graphics_messages.rs | 14 +- 11 files changed, 13 insertions(+), 2174 deletions(-) delete mode 100644 crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/avc_messages.rs delete mode 100644 crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/client.rs delete mode 100644 crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/mod.rs delete mode 100644 crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs delete mode 100644 crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/mod.rs delete mode 100644 crates/ironrdp-pdu/src/rdp/vc/dvc/mod.rs diff --git a/crates/ironrdp-pdu/src/lib.rs b/crates/ironrdp-pdu/src/lib.rs index c5465cc062..8db507d67e 100644 --- a/crates/ironrdp-pdu/src/lib.rs +++ b/crates/ironrdp-pdu/src/lib.rs @@ -30,7 +30,6 @@ pub(crate) mod crypto; pub(crate) mod per; pub use crate::basic_output::{bitmap, fast_path, pointer, slow_path, surface_commands}; -pub use crate::rdp::vc::dvc; pub type PduResult = Result; diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/avc_messages.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/avc_messages.rs deleted file mode 100644 index 65c5fb9896..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/avc_messages.rs +++ /dev/null @@ -1,226 +0,0 @@ -use core::fmt::Debug; - -use bit_field::BitField as _; -use bitflags::bitflags; -use ironrdp_core::{ - Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, - ensure_size, invalid_field_err, -}; - -use crate::geometry::InclusiveRectangle; - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct QuantQuality { - pub quantization_parameter: u8, - pub progressive: bool, - pub quality: u8, -} - -impl QuantQuality { - const NAME: &'static str = "GfxQuantQuality"; - - const FIXED_PART_SIZE: usize = 1 /* data */ + 1 /* quality */; -} - -impl Encode for QuantQuality { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - let mut data = 0u8; - data.set_bits(0..6, self.quantization_parameter); - data.set_bit(7, self.progressive); - dst.write_u8(data); - dst.write_u8(self.quality); - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'de> Decode<'de> for QuantQuality { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let data = src.read_u8(); - let qp = data.get_bits(0..6); - let progressive = data.get_bit(7); - let quality = src.read_u8(); - Ok(QuantQuality { - quantization_parameter: qp, - progressive, - quality, - }) - } -} - -#[derive(Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct Avc420BitmapStream<'a> { - pub rectangles: Vec, - pub quant_qual_vals: Vec, - pub data: &'a [u8], -} - -impl Debug for Avc420BitmapStream<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("Avc420BitmapStream") - .field("rectangles", &self.rectangles) - .field("quant_qual_vals", &self.quant_qual_vals) - .field("data_len", &self.data.len()) - .finish() - } -} - -impl Avc420BitmapStream<'_> { - const NAME: &'static str = "Avc420BitmapStream"; - - const FIXED_PART_SIZE: usize = 4 /* nRect */; -} - -impl Encode for Avc420BitmapStream<'_> { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u32(cast_length!("len", self.rectangles.len())?); - for rectangle in &self.rectangles { - rectangle.encode(dst)?; - } - for quant_qual_val in &self.quant_qual_vals { - quant_qual_val.encode(dst)?; - } - dst.write_slice(self.data); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - // Each rectangle is 8 bytes and 2 bytes for each quant val - Self::FIXED_PART_SIZE + self.rectangles.len() * 10 + self.data.len() - } -} - -impl<'de> Decode<'de> for Avc420BitmapStream<'de> { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let num_regions = cast_length!("number of regions", src.read_u32())?; - let mut rectangles = Vec::with_capacity(num_regions); - let mut quant_qual_vals = Vec::with_capacity(num_regions); - for _ in 0..num_regions { - rectangles.push(InclusiveRectangle::decode(src)?); - } - for _ in 0..num_regions { - quant_qual_vals.push(QuantQuality::decode(src)?); - } - let data = src.remaining(); - Ok(Avc420BitmapStream { - rectangles, - quant_qual_vals, - data, - }) - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] - pub struct Encoding: u8 { - const LUMA_AND_CHROMA = 0x00; - const LUMA = 0x01; - const CHROMA = 0x02; - - const _ = !0; - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct Avc444BitmapStream<'a> { - pub encoding: Encoding, - pub stream1: Avc420BitmapStream<'a>, - pub stream2: Option>, -} - -impl Avc444BitmapStream<'_> { - const NAME: &'static str = "Avc444BitmapStream"; - - const FIXED_PART_SIZE: usize = 4 /* streamInfo */; -} - -impl Encode for Avc444BitmapStream<'_> { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - let mut stream_info = 0u32; - stream_info.set_bits(0..30, cast_length!("stream1size", self.stream1.size())?); - stream_info.set_bits(30..32, u32::from(self.encoding.bits())); - dst.write_u32(stream_info); - self.stream1.encode(dst)?; - if let Some(stream) = self.stream2.as_ref() { - stream.encode(dst)?; - } - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - let stream2_size = if let Some(stream) = self.stream2.as_ref() { - stream.size() - } else { - 0 - }; - - Self::FIXED_PART_SIZE + self.stream1.size() + stream2_size - } -} - -impl<'de> Decode<'de> for Avc444BitmapStream<'de> { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let stream_info = src.read_u32(); - let stream_len = stream_info.get_bits(0..30); - let encoding = - Encoding::from_bits_retain(u8::try_from(stream_info.get_bits(30..32)).expect("value fits into u8")); - - if stream_len == 0 { - if encoding == Encoding::LUMA_AND_CHROMA { - return Err(invalid_field_err!("encoding", "invalid encoding")); - } - - let stream1 = Avc420BitmapStream::decode(src)?; - Ok(Avc444BitmapStream { - encoding, - stream1, - stream2: None, - }) - } else { - let (mut stream1, mut stream2) = src.split_at(cast_length!("first stream length", stream_len)?); - let stream1 = Avc420BitmapStream::decode(&mut stream1)?; - let stream2 = if encoding == Encoding::LUMA_AND_CHROMA { - Some(Avc420BitmapStream::decode(&mut stream2)?) - } else { - None - }; - Ok(Avc444BitmapStream { - encoding, - stream1, - stream2, - }) - } - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/client.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/client.rs deleted file mode 100644 index d5352fd873..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/client.rs +++ /dev/null @@ -1,179 +0,0 @@ -use core::iter; - -use ironrdp_core::{ - Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, - ensure_size, -}; - -use super::CapabilitySet; - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct CapabilitiesAdvertisePdu(pub Vec); - -impl CapabilitiesAdvertisePdu { - const NAME: &'static str = "CapabilitiesAdvertisePdu"; - - const FIXED_PART_SIZE: usize = 2 /* Count */; -} - -impl Encode for CapabilitiesAdvertisePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(cast_length!("Count", self.0.len())?); - - for capability_set in self.0.iter() { - capability_set.encode(dst)?; - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.0.iter().map(|c| c.size()).sum::() - } -} - -impl<'a> Decode<'a> for CapabilitiesAdvertisePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let capabilities_count = cast_length!("Count", src.read_u16())?; - - ensure_size!(in: src, size: capabilities_count * CapabilitySet::FIXED_PART_SIZE); - - let capabilities = iter::repeat_with(|| CapabilitySet::decode(src)) - .take(capabilities_count) - .collect::>()?; - - Ok(Self(capabilities)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct FrameAcknowledgePdu { - pub queue_depth: QueueDepth, - pub frame_id: u32, - pub total_frames_decoded: u32, -} - -impl FrameAcknowledgePdu { - const NAME: &'static str = "FrameAcknowledgePdu"; - - const FIXED_PART_SIZE: usize = 4 /* QueueDepth */ + 4 /* FrameId */ + 4 /* TotalFramesDecoded */; -} - -impl Encode for FrameAcknowledgePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u32(self.queue_depth.to_u32()); - dst.write_u32(self.frame_id); - dst.write_u32(self.total_frames_decoded); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for FrameAcknowledgePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let queue_depth = QueueDepth::from_u32(src.read_u32()); - let frame_id = src.read_u32(); - let total_frames_decoded = src.read_u32(); - - Ok(Self { - queue_depth, - frame_id, - total_frames_decoded, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct CacheImportReplyPdu { - pub cache_slots: Vec, -} - -impl CacheImportReplyPdu { - const NAME: &'static str = "CacheImportReplyPdu"; - - const FIXED_PART_SIZE: usize = 2 /* Count */; -} - -impl Encode for CacheImportReplyPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(cast_length!("Count", self.cache_slots.len())?); - - for cache_slot in self.cache_slots.iter() { - dst.write_u16(*cache_slot); - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.cache_slots.iter().map(|_| 2).sum::() - } -} - -impl<'a> Decode<'a> for CacheImportReplyPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let entries_count = usize::from(src.read_u16()); - - let cache_slots = iter::repeat_with(|| src.read_u16()).take(entries_count).collect(); - - Ok(Self { cache_slots }) - } -} - -#[repr(u32)] -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum QueueDepth { - Unavailable, - AvailableBytes(u32), - Suspend, -} - -impl QueueDepth { - pub fn from_u32(v: u32) -> Self { - match v { - 0x0000_0000 => Self::Unavailable, - 0x0000_0001..=0xFFFF_FFFE => Self::AvailableBytes(v), - 0xFFFF_FFFF => Self::Suspend, - } - } - - pub fn to_u32(self) -> u32 { - match self { - Self::Unavailable => 0x0000_0000, - Self::AvailableBytes(v) => v, - Self::Suspend => 0xFFFF_FFFF, - } - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/mod.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/mod.rs deleted file mode 100644 index 9b643fa98d..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/mod.rs +++ /dev/null @@ -1,373 +0,0 @@ -mod client; -mod server; - -mod avc_messages; -use bitflags::bitflags; -use num_derive::FromPrimitive; -use num_traits::FromPrimitive as _; - -#[rustfmt::skip] // do not re-order this -pub use avc_messages::{Avc420BitmapStream, Avc444BitmapStream, Encoding, QuantQuality}; -pub use client::{CacheImportReplyPdu, CapabilitiesAdvertisePdu, FrameAcknowledgePdu, QueueDepth}; -use ironrdp_core::{ - Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, - ensure_size, invalid_field_err, -}; -pub use server::{ - CacheToSurfacePdu, CapabilitiesConfirmPdu, Codec1Type, Codec2Type, CreateSurfacePdu, DeleteEncodingContextPdu, - DeleteSurfacePdu, EndFramePdu, EvictCacheEntryPdu, MapSurfaceToOutputPdu, MapSurfaceToScaledOutputPdu, - MapSurfaceToScaledWindowPdu, PixelFormat, ResetGraphicsPdu, SolidFillPdu, StartFramePdu, SurfaceToCachePdu, - SurfaceToSurfacePdu, Timestamp, WireToSurface1Pdu, WireToSurface2Pdu, -}; - -use super::RDP_GFX_HEADER_SIZE; - -const CAPABILITY_SET_HEADER_SIZE: usize = 8; - -const V10_1_RESERVED: u128 = 0; - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum CapabilitySet { - V8 { flags: CapabilitiesV8Flags }, - V8_1 { flags: CapabilitiesV81Flags }, - V10 { flags: CapabilitiesV10Flags }, - V10_1, - V10_2 { flags: CapabilitiesV10Flags }, - V10_3 { flags: CapabilitiesV103Flags }, - V10_4 { flags: CapabilitiesV104Flags }, - V10_5 { flags: CapabilitiesV104Flags }, - V10_6 { flags: CapabilitiesV104Flags }, - V10_6Err { flags: CapabilitiesV104Flags }, - V10_7 { flags: CapabilitiesV107Flags }, - Unknown(Vec), -} - -impl CapabilitySet { - const NAME: &'static str = "GfxCapabilitySet"; - - const FIXED_PART_SIZE: usize = CAPABILITY_SET_HEADER_SIZE; - - fn version(&self) -> CapabilityVersion { - match self { - CapabilitySet::V8 { .. } => CapabilityVersion::V8, - CapabilitySet::V8_1 { .. } => CapabilityVersion::V8_1, - CapabilitySet::V10 { .. } => CapabilityVersion::V10, - CapabilitySet::V10_1 => CapabilityVersion::V10_1, - CapabilitySet::V10_2 { .. } => CapabilityVersion::V10_2, - CapabilitySet::V10_3 { .. } => CapabilityVersion::V10_3, - CapabilitySet::V10_4 { .. } => CapabilityVersion::V10_4, - CapabilitySet::V10_5 { .. } => CapabilityVersion::V10_5, - CapabilitySet::V10_6 { .. } => CapabilityVersion::V10_6, - CapabilitySet::V10_6Err { .. } => CapabilityVersion::V10_6Err, - CapabilitySet::V10_7 { .. } => CapabilityVersion::V10_7, - CapabilitySet::Unknown { .. } => CapabilityVersion::Unknown, - } - } -} - -impl Encode for CapabilitySet { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u32(self.version().as_u32()); - dst.write_u32(cast_length!("dataLength", self.size() - CAPABILITY_SET_HEADER_SIZE)?); - - match self { - CapabilitySet::V8 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V8_1 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_1 => dst.write_u128(V10_1_RESERVED), - CapabilitySet::V10_2 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_3 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_4 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_5 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_6 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_6Err { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_7 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::Unknown(data) => dst.write_slice(data), - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - CAPABILITY_SET_HEADER_SIZE - + match self { - CapabilitySet::V8 { .. } - | CapabilitySet::V8_1 { .. } - | CapabilitySet::V10 { .. } - | CapabilitySet::V10_2 { .. } - | CapabilitySet::V10_3 { .. } - | CapabilitySet::V10_4 { .. } - | CapabilitySet::V10_5 { .. } - | CapabilitySet::V10_6 { .. } - | CapabilitySet::V10_6Err { .. } - | CapabilitySet::V10_7 { .. } => 4, - CapabilitySet::V10_1 => 16, - CapabilitySet::Unknown(data) => data.len(), - } - } -} - -impl<'de> Decode<'de> for CapabilitySet { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let version = CapabilityVersion::from_u32(src.read_u32()) - .ok_or_else(|| invalid_field_err!("version", "unhandled version"))?; - let data_length: usize = cast_length!("dataLength", src.read_u32())?; - - ensure_size!(in: src, size: data_length); - let data = src.read_slice(data_length); - let mut cur = ReadCursor::new(data); - - let size = match version { - CapabilityVersion::V8 - | CapabilityVersion::V8_1 - | CapabilityVersion::V10 - | CapabilityVersion::V10_2 - | CapabilityVersion::V10_3 - | CapabilityVersion::V10_4 - | CapabilityVersion::V10_5 - | CapabilityVersion::V10_6 - | CapabilityVersion::V10_6Err - | CapabilityVersion::V10_7 => 4, - CapabilityVersion::V10_1 => 16, - CapabilityVersion::Unknown => 0, - }; - - ensure_size!(in: cur, size: size); - match version { - CapabilityVersion::V8 => Ok(CapabilitySet::V8 { - flags: CapabilitiesV8Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V8_1 => Ok(CapabilitySet::V8_1 { - flags: CapabilitiesV81Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10 => Ok(CapabilitySet::V10 { - flags: CapabilitiesV10Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_1 => { - cur.read_u128(); - - Ok(CapabilitySet::V10_1) - } - CapabilityVersion::V10_2 => Ok(CapabilitySet::V10_2 { - flags: CapabilitiesV10Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_3 => Ok(CapabilitySet::V10_3 { - flags: CapabilitiesV103Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_4 => Ok(CapabilitySet::V10_4 { - flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_5 => Ok(CapabilitySet::V10_5 { - flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_6 => Ok(CapabilitySet::V10_6 { - flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_6Err => Ok(CapabilitySet::V10_6Err { - flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_7 => Ok(CapabilitySet::V10_7 { - flags: CapabilitiesV107Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::Unknown => Ok(CapabilitySet::Unknown(data.to_vec())), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct Color { - pub b: u8, - pub g: u8, - pub r: u8, - pub xa: u8, -} - -impl Color { - const NAME: &'static str = "GfxColor"; - - const FIXED_PART_SIZE: usize = 4 /* BGRA */; -} - -impl Encode for Color { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u8(self.b); - dst.write_u8(self.g); - dst.write_u8(self.r); - dst.write_u8(self.xa); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'de> Decode<'de> for Color { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let b = src.read_u8(); - let g = src.read_u8(); - let r = src.read_u8(); - let xa = src.read_u8(); - - Ok(Self { b, g, r, xa }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct Point { - pub x: u16, - pub y: u16, -} - -impl Point { - const NAME: &'static str = "GfxPoint"; - - const FIXED_PART_SIZE: usize = 2 /* X */ + 2 /* Y */; -} - -impl Encode for Point { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.x); - dst.write_u16(self.y); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'de> Decode<'de> for Point { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let x = src.read_u16(); - let y = src.read_u16(); - - Ok(Self { x, y }) - } -} - -#[repr(u32)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] -pub(crate) enum CapabilityVersion { - V8 = 0x8_0004, - V8_1 = 0x8_0105, - V10 = 0xa_0002, - V10_1 = 0xa_0100, - V10_2 = 0xa_0200, - V10_3 = 0xa_0301, - V10_4 = 0xa_0400, - V10_5 = 0xa_0502, - V10_6 = 0xa_0600, // [MS-RDPEGFX-errata] - V10_6Err = 0xa_0601, // defined similar to FreeRDP to maintain best compatibility - V10_7 = 0xa_0701, - Unknown = 0xa_0702, -} - -impl CapabilityVersion { - #[expect( - clippy::as_conversions, - reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" - )] - fn as_u32(self) -> u32 { - self as u32 - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] - pub struct CapabilitiesV8Flags: u32 { - const THIN_CLIENT = 0x1; - const SMALL_CACHE = 0x2; - - const _ = !0; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] - pub struct CapabilitiesV81Flags: u32 { - const THIN_CLIENT = 0x01; - const SMALL_CACHE = 0x02; - const AVC420_ENABLED = 0x10; - - const _ = !0; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] - pub struct CapabilitiesV10Flags: u32 { - const SMALL_CACHE = 0x02; - const AVC_DISABLED = 0x20; - - const _ = !0; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] - pub struct CapabilitiesV103Flags: u32 { - const AVC_DISABLED = 0x20; - const AVC_THIN_CLIENT = 0x40; - - const _ = !0; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] - pub struct CapabilitiesV104Flags: u32 { - const SMALL_CACHE = 0x02; - const AVC_DISABLED = 0x20; - const AVC_THIN_CLIENT = 0x40; - - const _ = !0; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] - pub struct CapabilitiesV107Flags: u32 { - const SMALL_CACHE = 0x02; - const AVC_DISABLED = 0x20; - const AVC_THIN_CLIENT = 0x40; - const SCALEDMAP_DISABLE = 0x80; - - const _ = !0; - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs deleted file mode 100644 index 2a5723aee1..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs +++ /dev/null @@ -1,1066 +0,0 @@ -use core::iter; -use std::fmt; - -use bit_field::BitField as _; -use ironrdp_core::{ - Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, decode_cursor, - ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, -}; -use num_derive::FromPrimitive; -use num_traits::FromPrimitive as _; - -use super::{CapabilitySet, Color, Point, RDP_GFX_HEADER_SIZE}; -use crate::gcc::Monitor; -use crate::geometry::InclusiveRectangle; - -pub(crate) const RESET_GRAPHICS_PDU_SIZE: usize = 340; - -const MAX_RESET_GRAPHICS_WIDTH_HEIGHT: u32 = 32_766; -const MONITOR_COUNT_MAX: usize = 16; - -#[derive(Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct WireToSurface1Pdu { - pub surface_id: u16, - pub codec_id: Codec1Type, - pub pixel_format: PixelFormat, - pub destination_rectangle: InclusiveRectangle, - pub bitmap_data: Vec, -} - -impl fmt::Debug for WireToSurface1Pdu { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("WireToSurface1Pdu") - .field("surface_id", &self.surface_id) - .field("codec_id", &self.codec_id) - .field("pixel_format", &self.pixel_format) - .field("destination_rectangle", &self.destination_rectangle) - .field("bitmap_data_length", &self.bitmap_data.len()) - .finish() - } -} - -impl WireToSurface1Pdu { - const NAME: &'static str = "WireToSurface1Pdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* CodecId */ + 1 /* PixelFormat */ + InclusiveRectangle::FIXED_PART_SIZE /* Dest */ + 4 /* BitmapDataLen */; -} - -impl Encode for WireToSurface1Pdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.surface_id); - dst.write_u16(self.codec_id.as_u16()); - dst.write_u8(self.pixel_format.as_u8()); - self.destination_rectangle.encode(dst)?; - dst.write_u32(cast_length!("BitmapDataLen", self.bitmap_data.len())?); - dst.write_slice(&self.bitmap_data); - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.bitmap_data.len() - } -} - -impl<'a> Decode<'a> for WireToSurface1Pdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let codec_id = - Codec1Type::from_u16(src.read_u16()).ok_or_else(|| invalid_field_err!("CodecId", "invalid codec ID"))?; - let pixel_format = PixelFormat::from_u8(src.read_u8()) - .ok_or_else(|| invalid_field_err!("PixelFormat", "invalid pixel format"))?; - let destination_rectangle = InclusiveRectangle::decode(src)?; - let bitmap_data_length = cast_length!("BitmapDataLen", src.read_u32())?; - - ensure_size!(in: src, size: bitmap_data_length); - let bitmap_data = src.read_slice(bitmap_data_length).to_vec(); - - Ok(Self { - surface_id, - codec_id, - pixel_format, - destination_rectangle, - bitmap_data, - }) - } -} - -#[derive(Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct WireToSurface2Pdu { - pub surface_id: u16, - pub codec_id: Codec2Type, - pub codec_context_id: u32, - pub pixel_format: PixelFormat, - pub bitmap_data: Vec, -} - -impl fmt::Debug for WireToSurface2Pdu { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("WireToSurface2Pdu") - .field("surface_id", &self.surface_id) - .field("codec_id", &self.codec_id) - .field("codec_context_id", &self.codec_context_id) - .field("pixel_format", &self.pixel_format) - .field("bitmap_data_length", &self.bitmap_data.len()) - .finish() - } -} - -impl WireToSurface2Pdu { - const NAME: &'static str = "WireToSurface2Pdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* CodecId */ + 4 /* ContextId */ + 1 /* PixelFormat */ + 4 /* BitmapDataLen */; -} - -impl Encode for WireToSurface2Pdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.surface_id); - dst.write_u16(self.codec_id.as_u16()); - dst.write_u32(self.codec_context_id); - dst.write_u8(self.pixel_format.as_u8()); - dst.write_u32(cast_length!("BitmapDataLen", self.bitmap_data.len())?); - dst.write_slice(&self.bitmap_data); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.bitmap_data.len() - } -} - -impl<'a> Decode<'a> for WireToSurface2Pdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let codec_id = - Codec2Type::from_u16(src.read_u16()).ok_or_else(|| invalid_field_err!("CodecId", "invalid codec ID"))?; - let codec_context_id = src.read_u32(); - let pixel_format = PixelFormat::from_u8(src.read_u8()) - .ok_or_else(|| invalid_field_err!("PixelFormat", "invalid pixel format"))?; - let bitmap_data_length = cast_length!("BitmapDataLen", src.read_u32())?; - - ensure_size!(in: src, size: bitmap_data_length); - let bitmap_data = src.read_slice(bitmap_data_length).to_vec(); - - Ok(Self { - surface_id, - codec_id, - codec_context_id, - pixel_format, - bitmap_data, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct DeleteEncodingContextPdu { - pub surface_id: u16, - pub codec_context_id: u32, -} - -impl DeleteEncodingContextPdu { - const NAME: &'static str = "DeleteEncodingContextPdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 4 /* CodecContextId */; -} - -impl Encode for DeleteEncodingContextPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u32(self.codec_context_id); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for DeleteEncodingContextPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let codec_context_id = src.read_u32(); - - Ok(Self { - surface_id, - codec_context_id, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct SolidFillPdu { - pub surface_id: u16, - pub fill_pixel: Color, - pub rectangles: Vec, -} - -impl SolidFillPdu { - const NAME: &'static str = "CacheToSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + Color::FIXED_PART_SIZE /* Color */ + 2 /* RectCount */; -} - -impl Encode for SolidFillPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.surface_id); - self.fill_pixel.encode(dst)?; - dst.write_u16(cast_length!("number of rectangles", self.rectangles.len())?); - - for rectangle in self.rectangles.iter() { - rectangle.encode(dst)?; - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.rectangles.iter().map(|r| r.size()).sum::() - } -} - -impl<'a> Decode<'a> for SolidFillPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let fill_pixel = Color::decode(src)?; - let rectangles_count = usize::from(src.read_u16()); - - ensure_size!(in: src, size: rectangles_count * InclusiveRectangle::FIXED_PART_SIZE); - let rectangles = iter::repeat_with(|| InclusiveRectangle::decode(src)) - .take(rectangles_count) - .collect::>()?; - - Ok(Self { - surface_id, - fill_pixel, - rectangles, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct SurfaceToSurfacePdu { - pub source_surface_id: u16, - pub destination_surface_id: u16, - pub source_rectangle: InclusiveRectangle, - pub destination_points: Vec, -} - -impl SurfaceToSurfacePdu { - const NAME: &'static str = "SurfaceToSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SourceId */ + 2 /* DestId */ + InclusiveRectangle::FIXED_PART_SIZE /* SourceRect */ + 2 /* DestPointsCount */; -} - -impl Encode for SurfaceToSurfacePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.source_surface_id); - dst.write_u16(self.destination_surface_id); - self.source_rectangle.encode(dst)?; - - dst.write_u16(cast_length!("DestinationPoints", self.destination_points.len())?); - for rectangle in self.destination_points.iter() { - rectangle.encode(dst)?; - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.destination_points.iter().map(|r| r.size()).sum::() - } -} - -impl<'a> Decode<'a> for SurfaceToSurfacePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let source_surface_id = src.read_u16(); - let destination_surface_id = src.read_u16(); - let source_rectangle = InclusiveRectangle::decode(src)?; - let destination_points_count = usize::from(src.read_u16()); - - let destination_points = iter::repeat_with(|| Point::decode(src)) - .take(destination_points_count) - .collect::>()?; - - Ok(Self { - source_surface_id, - destination_surface_id, - source_rectangle, - destination_points, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct SurfaceToCachePdu { - pub surface_id: u16, - pub cache_key: u64, - pub cache_slot: u16, - pub source_rectangle: InclusiveRectangle, -} - -impl SurfaceToCachePdu { - const NAME: &'static str = "SurfaceToCachePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 8 /* CacheKey */ + 2 /* CacheSlot */ + InclusiveRectangle::FIXED_PART_SIZE /* SourceRect */; -} - -impl Encode for SurfaceToCachePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u64(self.cache_key); - dst.write_u16(self.cache_slot); - self.source_rectangle.encode(dst)?; - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for SurfaceToCachePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let cache_key = src.read_u64(); - let cache_slot = src.read_u16(); - let source_rectangle = InclusiveRectangle::decode(src)?; - - Ok(Self { - surface_id, - cache_key, - cache_slot, - source_rectangle, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct CacheToSurfacePdu { - pub cache_slot: u16, - pub surface_id: u16, - pub destination_points: Vec, -} - -impl CacheToSurfacePdu { - const NAME: &'static str = "CacheToSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* cache_slot */ + 2 /* surface_id */ + 2 /* npoints */; -} - -impl Encode for CacheToSurfacePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.cache_slot); - dst.write_u16(self.surface_id); - dst.write_u16(cast_length!("npoints", self.destination_points.len())?); - for point in self.destination_points.iter() { - point.encode(dst)?; - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.destination_points.iter().map(|p| p.size()).sum::() - } -} - -impl<'de> Decode<'de> for CacheToSurfacePdu { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let cache_slot = src.read_u16(); - let surface_id = src.read_u16(); - let destination_points_count = usize::from(src.read_u16()); - - let destination_points = iter::repeat_with(|| decode_cursor(src)) - .take(destination_points_count) - .collect::>()?; - - Ok(Self { - cache_slot, - surface_id, - destination_points, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct CreateSurfacePdu { - pub surface_id: u16, - pub width: u16, - pub height: u16, - pub pixel_format: PixelFormat, -} - -impl CreateSurfacePdu { - const NAME: &'static str = "CreateSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* Width */ + 2 /* Height */ + 1 /* PixelFormat */; -} - -impl Encode for CreateSurfacePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u16(self.width); - dst.write_u16(self.height); - dst.write_u8(self.pixel_format.as_u8()); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for CreateSurfacePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let width = src.read_u16(); - let height = src.read_u16(); - let pixel_format = PixelFormat::from_u8(src.read_u8()) - .ok_or_else(|| invalid_field_err!("pixelFormat", "invalid pixel format"))?; - - Ok(Self { - surface_id, - width, - height, - pixel_format, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct DeleteSurfacePdu { - pub surface_id: u16, -} - -impl DeleteSurfacePdu { - const NAME: &'static str = "DeleteSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */; -} - -impl Encode for DeleteSurfacePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for DeleteSurfacePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - - Ok(Self { surface_id }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct ResetGraphicsPdu { - pub width: u32, - pub height: u32, - pub monitors: Vec, -} - -impl ResetGraphicsPdu { - const NAME: &'static str = "ResetGraphicsPdu"; - - const FIXED_PART_SIZE: usize = 4 /* Width */ + 4 /* Height */; - - fn padding_size(&self) -> usize { - RESET_GRAPHICS_PDU_SIZE - RDP_GFX_HEADER_SIZE - 12 - self.monitors.iter().map(|m| m.size()).sum::() - } -} - -impl Encode for ResetGraphicsPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u32(self.width); - dst.write_u32(self.height); - dst.write_u32(cast_length!("nMonitors", self.monitors.len())?); - - for monitor in self.monitors.iter() { - monitor.encode(dst)?; - } - - write_padding!(dst, self.padding_size()); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - RESET_GRAPHICS_PDU_SIZE - RDP_GFX_HEADER_SIZE - } -} - -impl<'a> Decode<'a> for ResetGraphicsPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let width = src.read_u32(); - if width > MAX_RESET_GRAPHICS_WIDTH_HEIGHT { - return Err(invalid_field_err!("width", "invalid reset graphics width")); - } - - let height = src.read_u32(); - if height > MAX_RESET_GRAPHICS_WIDTH_HEIGHT { - return Err(invalid_field_err!("height", "invalid reset graphics height")); - } - - let monitor_count = cast_length!("monitor count", src.read_u32())?; - if monitor_count > MONITOR_COUNT_MAX { - return Err(invalid_field_err!("height", "invalid reset graphics monitor count")); - } - - let monitors = iter::repeat_with(|| Monitor::decode(src)) - .take(monitor_count) - .collect::, _>>()?; - - let pdu = Self { - width, - height, - monitors, - }; - - read_padding!(src, pdu.padding_size()); - - Ok(pdu) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct MapSurfaceToOutputPdu { - pub surface_id: u16, - pub output_origin_x: u32, - pub output_origin_y: u32, -} - -impl MapSurfaceToOutputPdu { - const NAME: &'static str = "MapSurfaceToOutputPdu"; - - const FIXED_PART_SIZE: usize = 2 /* surfaceId */ + 2 /* reserved */ + 4 /* OutOriginX */ + 4 /* OutOriginY */; -} - -impl Encode for MapSurfaceToOutputPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u16(0); // reserved - dst.write_u32(self.output_origin_x); - dst.write_u32(self.output_origin_y); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for MapSurfaceToOutputPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let _reserved = src.read_u16(); - let output_origin_x = src.read_u32(); - let output_origin_y = src.read_u32(); - - Ok(Self { - surface_id, - output_origin_x, - output_origin_y, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct MapSurfaceToScaledOutputPdu { - pub surface_id: u16, - pub output_origin_x: u32, - pub output_origin_y: u32, - pub target_width: u32, - pub target_height: u32, -} - -impl MapSurfaceToScaledOutputPdu { - const NAME: &'static str = "MapSurfaceToScaledOutputPdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* reserved */ + 4 /* OutOriginX */ + 4 /* OutOriginY */ + 4 /* TargetWidth */ + 4 /* TargetHeight */; -} - -impl Encode for MapSurfaceToScaledOutputPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u16(0); // reserved - dst.write_u32(self.output_origin_x); - dst.write_u32(self.output_origin_y); - dst.write_u32(self.target_width); - dst.write_u32(self.target_height); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for MapSurfaceToScaledOutputPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let _reserved = src.read_u16(); - let output_origin_x = src.read_u32(); - let output_origin_y = src.read_u32(); - let target_width = src.read_u32(); - let target_height = src.read_u32(); - - Ok(Self { - surface_id, - output_origin_x, - output_origin_y, - target_width, - target_height, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct MapSurfaceToScaledWindowPdu { - pub surface_id: u16, - pub window_id: u64, - pub mapped_width: u32, - pub mapped_height: u32, - pub target_width: u32, - pub target_height: u32, -} - -impl MapSurfaceToScaledWindowPdu { - const NAME: &'static str = "MapSurfaceToScaledWindowPdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 8 /* WindowId */ + 4 /* MappedWidth */ + 4 /* MappedHeight */ + 4 /* TargetWidth */ + 4 /* TargetHeight */; -} - -impl Encode for MapSurfaceToScaledWindowPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - dst.write_u16(self.surface_id); - dst.write_u64(self.window_id); // reserved - dst.write_u32(self.mapped_width); - dst.write_u32(self.mapped_height); - dst.write_u32(self.target_width); - dst.write_u32(self.target_height); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for MapSurfaceToScaledWindowPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let window_id = src.read_u64(); - let mapped_width = src.read_u32(); - let mapped_height = src.read_u32(); - let target_width = src.read_u32(); - let target_height = src.read_u32(); - - Ok(Self { - surface_id, - window_id, - mapped_width, - mapped_height, - target_width, - target_height, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct EvictCacheEntryPdu { - pub cache_slot: u16, -} - -impl EvictCacheEntryPdu { - const NAME: &'static str = "EvictCacheEntryPdu"; - - const FIXED_PART_SIZE: usize = 2; -} - -impl Encode for EvictCacheEntryPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.cache_slot); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for EvictCacheEntryPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let cache_slot = src.read_u16(); - - Ok(Self { cache_slot }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct StartFramePdu { - pub timestamp: Timestamp, - pub frame_id: u32, -} - -impl StartFramePdu { - const NAME: &'static str = "StartFramePdu"; - - const FIXED_PART_SIZE: usize = Timestamp::FIXED_PART_SIZE + 4 /* FrameId */; -} - -impl Encode for StartFramePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - self.timestamp.encode(dst)?; - dst.write_u32(self.frame_id); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for StartFramePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let timestamp = Timestamp::decode(src)?; - let frame_id = src.read_u32(); - - Ok(Self { timestamp, frame_id }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct EndFramePdu { - pub frame_id: u32, -} - -impl EndFramePdu { - const NAME: &'static str = "EndFramePdu"; - - const FIXED_PART_SIZE: usize = 4; -} - -impl Encode for EndFramePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u32(self.frame_id); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for EndFramePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let frame_id = src.read_u32(); - - Ok(Self { frame_id }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct CapabilitiesConfirmPdu(pub CapabilitySet); - -impl CapabilitiesConfirmPdu { - const NAME: &'static str = "CapabilitiesConfirmPdu"; -} - -impl Encode for CapabilitiesConfirmPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - self.0.encode(dst) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - self.0.size() - } -} - -impl<'a> Decode<'a> for CapabilitiesConfirmPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - let capability_set = CapabilitySet::decode(src)?; - - Ok(Self(capability_set)) - } -} - -#[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum Codec1Type { - Uncompressed = 0x0, - RemoteFx = 0x3, - ClearCodec = 0x8, - Planar = 0xa, - Avc420 = 0xb, - Alpha = 0xc, - Avc444 = 0xe, - Avc444v2 = 0xf, -} - -impl Codec1Type { - #[expect( - clippy::as_conversions, - reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" - )] - fn as_u16(self) -> u16 { - self as u16 - } -} - -#[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum Codec2Type { - RemoteFxProgressive = 0x9, -} - -impl Codec2Type { - #[expect( - clippy::as_conversions, - reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" - )] - fn as_u16(self) -> u16 { - self as u16 - } -} - -#[repr(u8)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum PixelFormat { - XRgb = 0x20, - ARgb = 0x21, -} - -impl PixelFormat { - #[expect( - clippy::as_conversions, - reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" - )] - fn as_u8(self) -> u8 { - self as u8 - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub struct Timestamp { - pub milliseconds: u16, - pub seconds: u8, - pub minutes: u8, - pub hours: u16, -} - -impl Timestamp { - const NAME: &'static str = "Timestamp"; - - const FIXED_PART_SIZE: usize = 4; -} - -impl Encode for Timestamp { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - let mut timestamp: u32 = 0; - - timestamp.set_bits(..10, u32::from(self.milliseconds)); - timestamp.set_bits(10..16, u32::from(self.seconds)); - timestamp.set_bits(16..22, u32::from(self.minutes)); - timestamp.set_bits(22.., u32::from(self.hours)); - - dst.write_u32(timestamp); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for Timestamp { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let timestamp = src.read_u32(); - - let milliseconds = u16::try_from(timestamp.get_bits(..10)).expect("value fits into u16"); - let seconds = u8::try_from(timestamp.get_bits(10..16)).expect("value fits into u8"); - let minutes = u8::try_from(timestamp.get_bits(16..22)).expect("value fits into u8"); - let hours = u16::try_from(timestamp.get_bits(22..)).expect("value fits into u16"); - - Ok(Self { - milliseconds, - seconds, - minutes, - hours, - }) - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/mod.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/mod.rs deleted file mode 100644 index a72a67ca6a..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/mod.rs +++ /dev/null @@ -1,313 +0,0 @@ -mod graphics_messages; - -pub use graphics_messages::{ - Avc420BitmapStream, Avc444BitmapStream, CacheImportReplyPdu, CacheToSurfacePdu, CapabilitiesAdvertisePdu, - CapabilitiesConfirmPdu, CapabilitiesV8Flags, CapabilitiesV10Flags, CapabilitiesV81Flags, CapabilitiesV103Flags, - CapabilitiesV104Flags, CapabilitiesV107Flags, CapabilitySet, Codec1Type, Codec2Type, Color, CreateSurfacePdu, - DeleteEncodingContextPdu, DeleteSurfacePdu, Encoding, EndFramePdu, EvictCacheEntryPdu, FrameAcknowledgePdu, - MapSurfaceToOutputPdu, MapSurfaceToScaledOutputPdu, MapSurfaceToScaledWindowPdu, PixelFormat, Point, QuantQuality, - QueueDepth, ResetGraphicsPdu, SolidFillPdu, StartFramePdu, SurfaceToCachePdu, SurfaceToSurfacePdu, Timestamp, - WireToSurface1Pdu, WireToSurface2Pdu, -}; -use ironrdp_core::{ - Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, - ensure_size, invalid_field_err, -}; -use num_derive::FromPrimitive; -use num_traits::FromPrimitive as _; - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum ServerPdu { - WireToSurface1(WireToSurface1Pdu), - WireToSurface2(WireToSurface2Pdu), - DeleteEncodingContext(DeleteEncodingContextPdu), - SolidFill(SolidFillPdu), - SurfaceToSurface(SurfaceToSurfacePdu), - SurfaceToCache(SurfaceToCachePdu), - CacheToSurface(CacheToSurfacePdu), - EvictCacheEntry(EvictCacheEntryPdu), - CreateSurface(CreateSurfacePdu), - DeleteSurface(DeleteSurfacePdu), - StartFrame(StartFramePdu), - EndFrame(EndFramePdu), - ResetGraphics(ResetGraphicsPdu), - MapSurfaceToOutput(MapSurfaceToOutputPdu), - CapabilitiesConfirm(CapabilitiesConfirmPdu), - CacheImportReply(CacheImportReplyPdu), - MapSurfaceToScaledOutput(MapSurfaceToScaledOutputPdu), - MapSurfaceToScaledWindow(MapSurfaceToScaledWindowPdu), -} - -const RDP_GFX_HEADER_SIZE: usize = 2 /* PduType */ + 2 /* flags */ + 4 /* bufferLen */; - -impl ServerPdu { - const NAME: &'static str = "GfxServerPdu"; - - const FIXED_PART_SIZE: usize = RDP_GFX_HEADER_SIZE; -} - -impl Encode for ServerPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - let buffer_length = self.size(); - - dst.write_u16(ServerPduType::from(self).as_u16()); - dst.write_u16(0); // flags - dst.write_u32(cast_length!("bufferLen", buffer_length)?); - - match self { - ServerPdu::WireToSurface1(pdu) => pdu.encode(dst), - ServerPdu::WireToSurface2(pdu) => pdu.encode(dst), - ServerPdu::DeleteEncodingContext(pdu) => pdu.encode(dst), - ServerPdu::SolidFill(pdu) => pdu.encode(dst), - ServerPdu::SurfaceToSurface(pdu) => pdu.encode(dst), - ServerPdu::SurfaceToCache(pdu) => pdu.encode(dst), - ServerPdu::CacheToSurface(pdu) => pdu.encode(dst), - ServerPdu::CreateSurface(pdu) => pdu.encode(dst), - ServerPdu::DeleteSurface(pdu) => pdu.encode(dst), - ServerPdu::ResetGraphics(pdu) => pdu.encode(dst), - ServerPdu::MapSurfaceToOutput(pdu) => pdu.encode(dst), - ServerPdu::MapSurfaceToScaledOutput(pdu) => pdu.encode(dst), - ServerPdu::MapSurfaceToScaledWindow(pdu) => pdu.encode(dst), - ServerPdu::StartFrame(pdu) => pdu.encode(dst), - ServerPdu::EndFrame(pdu) => pdu.encode(dst), - ServerPdu::EvictCacheEntry(pdu) => pdu.encode(dst), - ServerPdu::CapabilitiesConfirm(pdu) => pdu.encode(dst), - ServerPdu::CacheImportReply(pdu) => pdu.encode(dst), - } - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - + match self { - ServerPdu::WireToSurface1(pdu) => pdu.size(), - ServerPdu::WireToSurface2(pdu) => pdu.size(), - ServerPdu::DeleteEncodingContext(pdu) => pdu.size(), - ServerPdu::SolidFill(pdu) => pdu.size(), - ServerPdu::SurfaceToSurface(pdu) => pdu.size(), - ServerPdu::SurfaceToCache(pdu) => pdu.size(), - ServerPdu::CacheToSurface(pdu) => pdu.size(), - ServerPdu::CreateSurface(pdu) => pdu.size(), - ServerPdu::DeleteSurface(pdu) => pdu.size(), - ServerPdu::ResetGraphics(pdu) => pdu.size(), - ServerPdu::MapSurfaceToOutput(pdu) => pdu.size(), - ServerPdu::MapSurfaceToScaledOutput(pdu) => pdu.size(), - ServerPdu::MapSurfaceToScaledWindow(pdu) => pdu.size(), - ServerPdu::StartFrame(pdu) => pdu.size(), - ServerPdu::EndFrame(pdu) => pdu.size(), - ServerPdu::EvictCacheEntry(pdu) => pdu.size(), - ServerPdu::CapabilitiesConfirm(pdu) => pdu.size(), - ServerPdu::CacheImportReply(pdu) => pdu.size(), - } - } -} - -impl<'a> Decode<'a> for ServerPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let pdu_type = ServerPduType::from_u16(src.read_u16()) - .ok_or_else(|| invalid_field_err!("serverPduType", "invalid pdu type"))?; - let _flags = src.read_u16(); - let pdu_length = cast_length!("pduLen", src.read_u32())?; - - let (server_pdu, buffer_length) = { - let pdu = match pdu_type { - ServerPduType::DeleteEncodingContext => { - ServerPdu::DeleteEncodingContext(DeleteEncodingContextPdu::decode(src)?) - } - ServerPduType::WireToSurface1 => ServerPdu::WireToSurface1(WireToSurface1Pdu::decode(src)?), - ServerPduType::WireToSurface2 => ServerPdu::WireToSurface2(WireToSurface2Pdu::decode(src)?), - ServerPduType::SolidFill => ServerPdu::SolidFill(SolidFillPdu::decode(src)?), - ServerPduType::SurfaceToSurface => ServerPdu::SurfaceToSurface(SurfaceToSurfacePdu::decode(src)?), - ServerPduType::SurfaceToCache => ServerPdu::SurfaceToCache(SurfaceToCachePdu::decode(src)?), - ServerPduType::CacheToSurface => ServerPdu::CacheToSurface(CacheToSurfacePdu::decode(src)?), - ServerPduType::EvictCacheEntry => ServerPdu::EvictCacheEntry(EvictCacheEntryPdu::decode(src)?), - ServerPduType::CreateSurface => ServerPdu::CreateSurface(CreateSurfacePdu::decode(src)?), - ServerPduType::DeleteSurface => ServerPdu::DeleteSurface(DeleteSurfacePdu::decode(src)?), - ServerPduType::StartFrame => ServerPdu::StartFrame(StartFramePdu::decode(src)?), - ServerPduType::EndFrame => ServerPdu::EndFrame(EndFramePdu::decode(src)?), - ServerPduType::ResetGraphics => ServerPdu::ResetGraphics(ResetGraphicsPdu::decode(src)?), - ServerPduType::MapSurfaceToOutput => ServerPdu::MapSurfaceToOutput(MapSurfaceToOutputPdu::decode(src)?), - ServerPduType::CapabilitiesConfirm => { - ServerPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu::decode(src)?) - } - ServerPduType::CacheImportReply => ServerPdu::CacheImportReply(CacheImportReplyPdu::decode(src)?), - ServerPduType::MapSurfaceToScaledOutput => { - ServerPdu::MapSurfaceToScaledOutput(MapSurfaceToScaledOutputPdu::decode(src)?) - } - ServerPduType::MapSurfaceToScaledWindow => { - ServerPdu::MapSurfaceToScaledWindow(MapSurfaceToScaledWindowPdu::decode(src)?) - } - _ => return Err(invalid_field_err!("pduType", "invalid pdu type")), - }; - let buffer_length = pdu.size(); - - (pdu, buffer_length) - }; - - if buffer_length != pdu_length { - Err(invalid_field_err!("len", "invalid pdu length")) - } else { - Ok(server_pdu) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum ClientPdu { - FrameAcknowledge(FrameAcknowledgePdu), - CapabilitiesAdvertise(CapabilitiesAdvertisePdu), -} - -impl ClientPdu { - const NAME: &'static str = "GfxClientPdu"; - - const FIXED_PART_SIZE: usize = RDP_GFX_HEADER_SIZE; -} - -impl Encode for ClientPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(ClientPduType::from(self).as_u16()); - dst.write_u16(0); // flags - dst.write_u32(cast_length!("bufferLen", self.size())?); - - match self { - ClientPdu::FrameAcknowledge(pdu) => pdu.encode(dst), - ClientPdu::CapabilitiesAdvertise(pdu) => pdu.encode(dst), - } - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - + match self { - ClientPdu::FrameAcknowledge(pdu) => pdu.size(), - ClientPdu::CapabilitiesAdvertise(pdu) => pdu.size(), - } - } -} - -impl<'a> Decode<'a> for ClientPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - let pdu_type = ClientPduType::from_u16(src.read_u16()) - .ok_or_else(|| invalid_field_err!("clientPduType", "invalid pdu type"))?; - let _flags = src.read_u16(); - let pdu_length = cast_length!("bufferLen", src.read_u32())?; - - let client_pdu = match pdu_type { - ClientPduType::FrameAcknowledge => ClientPdu::FrameAcknowledge(FrameAcknowledgePdu::decode(src)?), - ClientPduType::CapabilitiesAdvertise => { - ClientPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::decode(src)?) - } - _ => return Err(invalid_field_err!("pduType", "invalid pdu type")), - }; - - if client_pdu.size() != pdu_length { - Err(invalid_field_err!("len", "invalid pdu length")) - } else { - Ok(client_pdu) - } - } -} - -#[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum ClientPduType { - FrameAcknowledge = 0x0d, - CacheImportOffer = 0x10, - CapabilitiesAdvertise = 0x12, - QoeFrameAcknowledge = 0x16, -} - -impl ClientPduType { - #[expect( - clippy::as_conversions, - reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" - )] - fn as_u16(self) -> u16 { - self as u16 - } -} - -impl<'a> From<&'a ClientPdu> for ClientPduType { - fn from(c: &'a ClientPdu) -> Self { - match c { - ClientPdu::FrameAcknowledge(_) => Self::FrameAcknowledge, - ClientPdu::CapabilitiesAdvertise(_) => Self::CapabilitiesAdvertise, - } - } -} - -#[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -pub enum ServerPduType { - WireToSurface1 = 0x01, - WireToSurface2 = 0x02, - DeleteEncodingContext = 0x03, - SolidFill = 0x04, - SurfaceToSurface = 0x05, - SurfaceToCache = 0x06, - CacheToSurface = 0x07, - EvictCacheEntry = 0x08, - CreateSurface = 0x09, - DeleteSurface = 0x0a, - StartFrame = 0x0b, - EndFrame = 0x0c, - ResetGraphics = 0x0e, - MapSurfaceToOutput = 0x0f, - CacheImportReply = 0x11, - CapabilitiesConfirm = 0x13, - MapSurfaceToWindow = 0x15, - MapSurfaceToScaledOutput = 0x17, - MapSurfaceToScaledWindow = 0x18, -} - -impl ServerPduType { - #[expect( - clippy::as_conversions, - reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" - )] - fn as_u16(self) -> u16 { - self as u16 - } -} - -impl<'a> From<&'a ServerPdu> for ServerPduType { - fn from(s: &'a ServerPdu) -> Self { - match s { - ServerPdu::WireToSurface1(_) => Self::WireToSurface1, - ServerPdu::WireToSurface2(_) => Self::WireToSurface2, - ServerPdu::DeleteEncodingContext(_) => Self::DeleteEncodingContext, - ServerPdu::SolidFill(_) => Self::SolidFill, - ServerPdu::SurfaceToSurface(_) => Self::SurfaceToSurface, - ServerPdu::SurfaceToCache(_) => Self::SurfaceToCache, - ServerPdu::CacheToSurface(_) => Self::CacheToSurface, - ServerPdu::EvictCacheEntry(_) => Self::EvictCacheEntry, - ServerPdu::CreateSurface(_) => Self::CreateSurface, - ServerPdu::DeleteSurface(_) => Self::DeleteSurface, - ServerPdu::StartFrame(_) => Self::StartFrame, - ServerPdu::EndFrame(_) => Self::EndFrame, - ServerPdu::ResetGraphics(_) => Self::ResetGraphics, - ServerPdu::MapSurfaceToOutput(_) => Self::MapSurfaceToOutput, - ServerPdu::MapSurfaceToScaledOutput(_) => Self::MapSurfaceToScaledOutput, - ServerPdu::MapSurfaceToScaledWindow(_) => Self::MapSurfaceToScaledWindow, - ServerPdu::CapabilitiesConfirm(_) => Self::CapabilitiesConfirm, - ServerPdu::CacheImportReply(_) => Self::CacheImportReply, - } - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/mod.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/mod.rs deleted file mode 100644 index 4fcc11bbbb..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod gfx; diff --git a/crates/ironrdp-pdu/src/rdp/vc/mod.rs b/crates/ironrdp-pdu/src/rdp/vc/mod.rs index 2cc02a3424..81c0e217ca 100644 --- a/crates/ironrdp-pdu/src/rdp/vc/mod.rs +++ b/crates/ironrdp-pdu/src/rdp/vc/mod.rs @@ -1,5 +1,3 @@ -pub mod dvc; - #[cfg(test)] mod tests; diff --git a/crates/ironrdp-testsuite-core/Cargo.toml b/crates/ironrdp-testsuite-core/Cargo.toml index f86d8559d7..9c6961c96b 100644 --- a/crates/ironrdp-testsuite-core/Cargo.toml +++ b/crates/ironrdp-testsuite-core/Cargo.toml @@ -26,6 +26,7 @@ harness = true array-concat = "0.5" expect-test = "1" ironrdp-core.path = "../ironrdp-core" +ironrdp-egfx.path = "../ironrdp-egfx" ironrdp-pdu.path = "../ironrdp-pdu" paste = "1" openh264 = { version = "0.9", optional = true, default-features = false, features = ["source"] } @@ -42,7 +43,6 @@ ironrdp-connector.path = "../ironrdp-connector" ironrdp-displaycontrol.path = "../ironrdp-displaycontrol" ironrdp-dvc.path = "../ironrdp-dvc" ironrdp-echo.path = "../ironrdp-echo" -ironrdp-egfx = { path = "../ironrdp-egfx" } ironrdp-fuzzing.path = "../ironrdp-fuzzing" ironrdp-graphics.path = "../ironrdp-graphics" ironrdp-str.path = "../ironrdp-str" diff --git a/crates/ironrdp-testsuite-core/src/gfx.rs b/crates/ironrdp-testsuite-core/src/gfx.rs index b96160485d..9e9a4589de 100644 --- a/crates/ironrdp-testsuite-core/src/gfx.rs +++ b/crates/ironrdp-testsuite-core/src/gfx.rs @@ -1,6 +1,6 @@ use std::sync::LazyLock; -use ironrdp_pdu::rdp::vc::dvc::gfx::{ClientPdu, ServerPdu}; +use ironrdp_egfx::pdu::GfxPdu; use crate::graphics_messages::{ FRAME_ACKNOWLEDGE, FRAME_ACKNOWLEDGE_BUFFER, WIRE_TO_SURFACE_1, WIRE_TO_SURFACE_1_BUFFER, @@ -13,7 +13,7 @@ pub static HEADER_WITH_WIRE_TO_SURFACE_1_BUFFER: LazyLock> = LazyLock::new(|| [&WIRE_TO_SURFACE_1_HEADER_BUFFER[..], &WIRE_TO_SURFACE_1_BUFFER[..]].concat()); pub static HEADER_WITH_FRAME_ACKNOWLEDGE_BUFFER: LazyLock> = LazyLock::new(|| [&FRAME_ACKNOWLEDGE_HEADER_BUFFER[..], &FRAME_ACKNOWLEDGE_BUFFER[..]].concat()); -pub static HEADER_WITH_WIRE_TO_SURFACE_1: LazyLock = - LazyLock::new(|| ServerPdu::WireToSurface1(WIRE_TO_SURFACE_1.clone())); -pub static HEADER_WITH_FRAME_ACKNOWLEDGE: LazyLock = - LazyLock::new(|| ClientPdu::FrameAcknowledge(FRAME_ACKNOWLEDGE.clone())); +pub static HEADER_WITH_WIRE_TO_SURFACE_1: LazyLock = + LazyLock::new(|| GfxPdu::WireToSurface1(WIRE_TO_SURFACE_1.clone())); +pub static HEADER_WITH_FRAME_ACKNOWLEDGE: LazyLock = + LazyLock::new(|| GfxPdu::FrameAcknowledge(FRAME_ACKNOWLEDGE.clone())); diff --git a/crates/ironrdp-testsuite-core/src/graphics_messages.rs b/crates/ironrdp-testsuite-core/src/graphics_messages.rs index 10460475cf..b76c7337d7 100644 --- a/crates/ironrdp-testsuite-core/src/graphics_messages.rs +++ b/crates/ironrdp-testsuite-core/src/graphics_messages.rs @@ -1,8 +1,6 @@ use std::sync::LazyLock; -use ironrdp_pdu::gcc::{Monitor, MonitorFlags}; -use ironrdp_pdu::geometry::InclusiveRectangle; -use ironrdp_pdu::rdp::vc::dvc::gfx::{ +use ironrdp_egfx::pdu::{ Avc420BitmapStream, Avc444BitmapStream, CacheImportReplyPdu, CacheToSurfacePdu, CapabilitiesAdvertisePdu, CapabilitiesConfirmPdu, CapabilitiesV8Flags, CapabilitiesV10Flags, CapabilitiesV81Flags, CapabilitiesV103Flags, CapabilitiesV104Flags, CapabilitySet, Codec1Type, Codec2Type, Color, CreateSurfacePdu, DeleteEncodingContextPdu, @@ -10,6 +8,8 @@ use ironrdp_pdu::rdp::vc::dvc::gfx::{ PixelFormat, Point, QuantQuality, QueueDepth, ResetGraphicsPdu, SolidFillPdu, StartFramePdu, SurfaceToCachePdu, SurfaceToSurfacePdu, Timestamp, WireToSurface1Pdu, WireToSurface2Pdu, }; +use ironrdp_pdu::gcc::{Monitor, MonitorFlags}; +use ironrdp_pdu::geometry::{ExclusiveRectangle, InclusiveRectangle}; pub const WIRE_TO_SURFACE_1_BUFFER: [u8; 218] = [ 0x00, 0x00, 0x08, 0x00, 0x20, 0xa5, 0x03, 0xde, 0x02, 0xab, 0x03, 0xe7, 0x02, 0xc9, 0x00, 0x00, 0x00, 0x01, 0x0e, @@ -239,7 +239,7 @@ pub static WIRE_TO_SURFACE_1: LazyLock = LazyLock::new(|| Wir surface_id: 0, codec_id: Codec1Type::ClearCodec, pixel_format: PixelFormat::XRgb, - destination_rectangle: InclusiveRectangle { + destination_rectangle: ExclusiveRectangle { left: 933, top: 734, right: 939, @@ -268,7 +268,7 @@ pub static SOLID_FILL: LazyLock = LazyLock::new(|| SolidFillPdu { r: 0, xa: 0, }, - rectangles: vec![InclusiveRectangle { + rectangles: vec![ExclusiveRectangle { left: 0, top: 0, right: 64, @@ -278,7 +278,7 @@ pub static SOLID_FILL: LazyLock = LazyLock::new(|| SolidFillPdu { pub static SURFACE_TO_SURFACE: LazyLock = LazyLock::new(|| SurfaceToSurfacePdu { source_surface_id: 0, destination_surface_id: 0, - source_rectangle: InclusiveRectangle { + source_rectangle: ExclusiveRectangle { left: 200, top: 60, right: 676, @@ -290,7 +290,7 @@ pub static SURFACE_TO_CACHE: LazyLock = LazyLock::new(|| Surf surface_id: 0, cache_key: 0x113D_86DA_A6A3_7FB7, cache_slot: 14, - source_rectangle: InclusiveRectangle { + source_rectangle: ExclusiveRectangle { left: 640, top: 0, right: 704, From 0bbffcd0ec54eb9a14950db5f65f9a164dabc05d Mon Sep 17 00:00:00 2001 From: Eitvilas Date: Mon, 25 May 2026 14:27:04 +0300 Subject: [PATCH 236/325] fix(web): include Meta keys in WebKit scancode dispatch (#1304) --- web-client/iron-remote-desktop/src/lib/scancodes.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web-client/iron-remote-desktop/src/lib/scancodes.ts b/web-client/iron-remote-desktop/src/lib/scancodes.ts index 4c83d60b42..eb24ee2040 100644 --- a/web-client/iron-remote-desktop/src/lib/scancodes.ts +++ b/web-client/iron-remote-desktop/src/lib/scancodes.ts @@ -157,7 +157,7 @@ const scanCodeToKeyCode = { '0xE06D': 'MediaSelect', }; -const codeToScanCodeBlinkOverride = { +const scanCodeToKeyCodeExtras = { '0x0077': 'Lang4', '0x0078': 'Lang3', '0xE008': 'Undo', @@ -175,7 +175,7 @@ const codeToScanCodeBlinkOverride = { '0xE063': 'WakeUp', }; -const scanCodeToKeyCodeGeckoOverride = { +const scanCodeToKeyCodeGeckoExtras = { '0x0054': 'PrintScreen', '0xE020': 'VolumeMute', // The documentation says it's 'AudioVolumeMute', but the actual test shows that it's 'VolumeMute'. '0xE02E': 'VolumeDown', @@ -185,9 +185,9 @@ const scanCodeToKeyCodeGeckoOverride = { }; const KeyCodeToScanCode = { - blink: invertCodesMapping({ ...scanCodeToKeyCode, ...codeToScanCodeBlinkOverride }), - gecko: invertCodesMapping({ ...scanCodeToKeyCode, ...scanCodeToKeyCodeGeckoOverride }), - webkit: invertCodesMapping(scanCodeToKeyCode), + blink: invertCodesMapping({ ...scanCodeToKeyCode, ...scanCodeToKeyCodeExtras }), + gecko: invertCodesMapping({ ...scanCodeToKeyCode, ...scanCodeToKeyCodeGeckoExtras }), + webkit: invertCodesMapping({ ...scanCodeToKeyCode, ...scanCodeToKeyCodeExtras }), }; function invertCodesMapping(obj: CodeMap) { From 894007448a005e965619571b079f89bcff14efd0 Mon Sep 17 00:00:00 2001 From: uchouT Date: Mon, 25 May 2026 21:10:05 +0800 Subject: [PATCH 237/325] refactor(rdpeusb): centralize SHARED_MSG_HEADER validation (#1294) --- crates/ironrdp-rdpeusb/Cargo.toml | 4 + crates/ironrdp-rdpeusb/src/pdu/caps.rs | 113 +-- .../ironrdp-rdpeusb/src/pdu/completion/mod.rs | 208 ++--- .../src/pdu/completion/ts_urb_result.rs | 504 ----------- crates/ironrdp-rdpeusb/src/pdu/header.rs | 137 +-- crates/ironrdp-rdpeusb/src/pdu/mod.rs | 188 ++-- crates/ironrdp-rdpeusb/src/pdu/notify.rs | 128 +-- crates/ironrdp-rdpeusb/src/pdu/sink.rs | 113 +-- crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs | 848 ++++-------------- .../src/pdu/usb_dev/ts_urb/mod.rs | 745 --------------- .../src/pdu/usb_dev/ts_urb/utils.rs | 112 --- crates/ironrdp-rdpeusb/src/pdu/utils.rs | 24 - 12 files changed, 425 insertions(+), 2699 deletions(-) diff --git a/crates/ironrdp-rdpeusb/Cargo.toml b/crates/ironrdp-rdpeusb/Cargo.toml index a5e2a91252..802cc95428 100644 --- a/crates/ironrdp-rdpeusb/Cargo.toml +++ b/crates/ironrdp-rdpeusb/Cargo.toml @@ -12,6 +12,10 @@ categories.workspace = true publish = false +[lib] +doctest = false +test = false + [features] default = [] std = [] diff --git a/crates/ironrdp-rdpeusb/src/pdu/caps.rs b/crates/ironrdp-rdpeusb/src/pdu/caps.rs index 5b2182a76f..a78fb0a5e6 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/caps.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/caps.rs @@ -33,7 +33,7 @@ impl Capability { #[doc(alias = "RIM_EXCHANGE_CAPABILITY_REQUEST")] #[derive(Debug, PartialEq)] pub struct RimExchangeCapabilityRequest { - pub header: SharedMsgHeader, + pub msg_id: MessageId, pub capability: Capability, } @@ -42,16 +42,16 @@ impl RimExchangeCapabilityRequest { pub const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_REQ; - pub fn header(msg_id: MessageId) -> SharedMsgHeader { + pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { interface_id: InterfaceId::CAPABILITIES, mask: Mask::StreamIdNone, - msg_id, + msg_id: self.msg_id, function_id: Some(FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST), } } - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); if src.read_u32() != 1 { return Err(invalid_field_err!( @@ -60,7 +60,7 @@ impl RimExchangeCapabilityRequest { )); } Ok(Self { - header, + msg_id: header.msg_id, capability: Capability::RimCapabilityVersion01, }) } @@ -69,26 +69,7 @@ impl RimExchangeCapabilityRequest { impl Encode for RimExchangeCapabilityRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - // ensure_interface_id!( - // self.header, - // "RIM_EXCHANGE_CAPABILITY_REQUEST", - // InterfaceId::CAPABILITIES, - // "0x0" - // ); - // ensure_mask!( - // self.header, - // "RIM_EXCHANGE_CAPABILITY_REQUEST", - // Mask::StreamIdNone, - // "0x0 (STREAM_ID_NONE)" - // ); - // ensure_function_id!( - // self.header, - // "RIM_EXCHANGE_CAPABILITY_REQUEST", - // FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST, - // "0x100 (RIM_EXCHANGE_CAPABILITY_REQUEST)" - // ); - - self.header.encode(dst)?; + self.header().encode(dst)?; #[expect(clippy::as_conversions)] dst.write_u32(self.capability as u32); @@ -114,7 +95,7 @@ impl Encode for RimExchangeCapabilityRequest { #[doc(alias = "RIM_EXCHANGE_CAPABILITY_RESPONSE")] #[derive(Debug, PartialEq)] pub struct RimExchangeCapabilityResponse { - pub header: SharedMsgHeader, + pub msg_id: MessageId, pub capability: Capability, pub result: HResult, } @@ -124,18 +105,17 @@ impl RimExchangeCapabilityResponse { pub const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_RSP; - pub fn header(msg_id: MessageId) -> SharedMsgHeader { + pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { interface_id: InterfaceId::CAPABILITIES, mask: Mask::StreamIdNone, - msg_id, + msg_id: self.msg_id, function_id: None, } } - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); - if src.read_u32() != 1 { return Err(invalid_field_err!( "RIM_EXCHANGE_CAPABILITY_RESPONSE::CapabilityValue", @@ -145,7 +125,7 @@ impl RimExchangeCapabilityResponse { let result = src.read_u32(); Ok(Self { - header, + msg_id: header.msg_id, capability: Capability::RimCapabilityVersion01, result, }) @@ -155,21 +135,7 @@ impl RimExchangeCapabilityResponse { impl Encode for RimExchangeCapabilityResponse { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - // ensure_interface_id!( - // self.header, - // "RIM_EXCHANGE_CAPABILITY_RESPONSE", - // InterfaceId::CAPABILITIES, - // "0x0" - // ); - // ensure_mask!( - // self.header, - // "RIM_EXCHANGE_CAPABILITY_RESPONSE", - // Mask::StreamIdNone, - // "0x0 (STREAM_ID_NONE)" - // ); - // ensure_function_id!(self.header, "RIM_EXCHANGE_CAPABILITY_RESPONSE"); - - self.header.encode(dst)?; + self.header().encode(dst)?; #[expect(clippy::as_conversions)] dst.write_u32(self.capability as u32); @@ -187,58 +153,3 @@ impl Encode for RimExchangeCapabilityResponse { Self::FIXED_PART_SIZE } } - -#[cfg(test)] -mod tests { - use alloc::vec::Vec; - - use ironrdp_core::{Decode as _, Encode as _}; - - // use crate::pdu::{ - // caps::{RimExchangeCapabilityRequest, RimExchangeCapabilityResponse}, - // header::SharedMsgHeader, - // }; - use super::*; - - #[test] - fn req() { - let mut wire = Vec::from([0; RimExchangeCapabilityRequest::FIXED_PART_SIZE]); - let mut dst = WriteCursor::new(&mut wire); - let header_en = RimExchangeCapabilityRequest::header(1234); - let packet_en = RimExchangeCapabilityRequest { - header: header_en, - capability: Capability::RimCapabilityVersion01, - }; - assert!(packet_en.encode(&mut dst).is_ok()); - - let mut src = ReadCursor::new(&wire); - let header_de = SharedMsgHeader::decode(&mut src).unwrap(); - // assert_eq!(header_en, header_de); - let packet_de = RimExchangeCapabilityRequest::decode(&mut src, header_de).unwrap(); - - assert_eq!(packet_en, packet_de); - } - - #[test] - fn rsp() { - let mut wire = Vec::from([0; RimExchangeCapabilityResponse::FIXED_PART_SIZE]); - let mut dst = WriteCursor::new(&mut wire); - let header_en = RimExchangeCapabilityResponse::header(1234); - - let packet_en = RimExchangeCapabilityResponse { - header: header_en, - capability: Capability::RimCapabilityVersion01, - result: 0, - }; - // crate_debug!(&packet_en); - assert!(packet_en.encode(&mut dst).is_ok()); - - let mut src = ReadCursor::new(&wire); - let header_de = SharedMsgHeader::decode(&mut src).unwrap(); - // crate_debug!(&header_de); - let packet_de = RimExchangeCapabilityResponse::decode(&mut src, header_de).unwrap(); - // crate_debug!(&packet_de); - - assert_eq!(packet_en, packet_de); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs index 92f9bfb528..8e5f26a474 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs @@ -14,7 +14,7 @@ use ironrdp_core::{ use ironrdp_pdu::utils::strict_sum; use crate::pdu::completion::ts_urb_result::{TsUrbIsochTransferResult, TsUrbResult, TsUrbResultPayload}; -use crate::pdu::header::SharedMsgHeader; +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; #[cfg(doc)] use crate::pdu::usb_dev::{ InternalIoControl, IoControl, RegisterRequestCallback, TransferInRequest, TransferOutRequest, @@ -57,7 +57,10 @@ const HRESULT_FROM_WIN32_ERROR_INSUFFICIENT_BUFFER: u32 = HRESULT_FROM_WIN32!(ER #[doc(alias = "IOCONTROL_COMPLETION")] #[derive(Debug, PartialEq, Clone)] pub struct IoControlCompletion { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + /// The interface ID provided by the server in the `RequestCompletion` field of the prior + /// [`RegisterRequestCallback`] message. + pub completion_iface: InterfaceId, pub request_id: RequestIdIoctl, pub hresult: HResult, pub information: u32, @@ -66,8 +69,17 @@ pub struct IoControlCompletion { } impl IoControlCompletion { - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { - const FIXED: usize = size_of::() + size_of::() + size_of::() + size_of::(); + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.completion_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::IOCONTROL_COMPLETION), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + const FIXED: usize = 4 /* RequestId */ + 4 /* HResult */ + 4 /* Information */ + 4 /* OutputBufferSize */; ensure_size!(in: src, size: FIXED); let request_id = src.read_u32(); @@ -107,7 +119,8 @@ impl IoControlCompletion { }; Ok(Self { - header, + msg_id: header.msg_id, + completion_iface: header.interface_id, request_id, hresult, information, @@ -121,7 +134,7 @@ impl Encode for IoControlCompletion { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(self.request_id); dst.write_u32(self.hresult); @@ -168,7 +181,10 @@ impl Encode for IoControlCompletion { #[doc(alias = "URB_COMPLETION")] #[derive(Debug, PartialEq, Clone)] pub struct UrbCompletion { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + /// The interface ID provided by the server in the `RequestCompletion` field of the prior + /// [`RegisterRequestCallback`] message. + pub completion_iface: InterfaceId, pub req_id: RequestIdTransferInOut, pub ts_urb_result: TsUrbResult, pub hresult: HResult, @@ -176,8 +192,17 @@ pub struct UrbCompletion { } impl UrbCompletion { - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { - ensure_size!(in: src, size: size_of::(/* RequestId */) + size_of::(/* CbTsUrbResult */)); + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.completion_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::URB_COMPLETION), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* RequestId */ + 4 /* CbTsUrbResult */); let req_id = RequestIdTransferInOut::try_from(src.read_u32()) .map_err(|reason| invalid_field_err!("URB_COMPLETION::RequestId", reason))?; @@ -194,13 +219,14 @@ impl UrbCompletion { TsUrbResultPayload::Isoch(TsUrbIsochTransferResult::decode(&mut ReadCursor::new(&bytes))?) }; - ensure_size!(in: src, size: size_of::(/* HResult */) + size_of::(/* OutputBufferSize */)); + ensure_size!(in: src, size: 4 /* HResult */ + 4 /* OutputBufferSize */); let hresult = src.read_u32(); let output_buffer_size = usize::try_from(src.read_u32()).map_err(|e| other_err!(source: e))?; ensure_size!(in: src, size: output_buffer_size); let output_buffer = src.read_slice(output_buffer_size).to_vec(); Ok(Self { - header, + msg_id: header.msg_id, + completion_iface: header.interface_id, req_id, ts_urb_result, hresult, @@ -212,7 +238,7 @@ impl UrbCompletion { impl Encode for UrbCompletion { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(self.req_id.into()); match u32::try_from(self.ts_urb_result.size()) { Ok(cb_ts_urb_result) => dst.write_u32(cb_ts_urb_result), @@ -258,7 +284,10 @@ impl Encode for UrbCompletion { #[doc(alias = "URB_COMPLETION_NO_DATA")] #[derive(Debug, PartialEq, Clone)] pub struct UrbCompletionNoData { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + /// The interface ID provided by the server in the `RequestCompletion` field of the prior + /// [`RegisterRequestCallback`] message. + pub completion_iface: InterfaceId, pub req_id: RequestIdTransferInOut, pub ts_urb_result: TsUrbResult, pub hresult: HResult, @@ -266,19 +295,29 @@ pub struct UrbCompletionNoData { } impl UrbCompletionNoData { - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { - ensure_size!(in: src, size: size_of::(/* RequestId */) + size_of::(/* CbTsUrbResult */)); + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.completion_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::URB_COMPLETION_NO_DATA), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* RequestId */ + 4 /* CbTsUrbResult */); let req_id = RequestIdTransferInOut::try_from(src.read_u32()) - .map_err(|reason| invalid_field_err!("URB_COMPLETION::RequestId", reason))?; + .map_err(|reason| invalid_field_err!("URB_COMPLETION_NO_DATA::RequestId", reason))?; let cb_ts_urb_result = usize::try_from(src.read_u32()).map_err(|e| other_err!(source: e))?; ensure_size!(in: src, size: cb_ts_urb_result); let ts_urb_result = TsUrbResult::decode(&mut ReadCursor::new(src.read_slice(cb_ts_urb_result)))?; - ensure_size!(in: src, size: size_of::(/* HResult */) + size_of::(/* OutputBufferSize */)); + ensure_size!(in: src, size: 4 /* HResult */ + 4 /* OutputBufferSize */); let hresult = src.read_u32(); let output_buffer_size = src.read_u32(); Ok(Self { - header, + msg_id: header.msg_id, + completion_iface: header.interface_id, req_id, ts_urb_result, hresult, @@ -290,7 +329,7 @@ impl UrbCompletionNoData { impl Encode for UrbCompletionNoData { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(self.req_id.into()); match self.ts_urb_result.size().try_into() { Ok(cb_ts_urb_result) => dst.write_u32(cb_ts_urb_result), @@ -315,134 +354,3 @@ impl Encode for UrbCompletionNoData { + size_of::(/* OutputBufferSize */) } } - -#[cfg(test)] -mod tests { - extern crate std; - use alloc::vec; - - use ts_urb_result::TsUrbResultHeader; - - use super::*; - use crate::pdu::completion::ts_urb_result::TsUrbGetCurrFrameNumResult; - use crate::pdu::header::{FunctionId, InterfaceId}; - use crate::pdu::utils::{UsbdIsoPacketDesc, round_trip}; - - #[test] - fn iocontrol_completion() { - let mut en = IoControlCompletion { - header: SharedMsgHeader { - interface_id: InterfaceId(25), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 14, - function_id: Some(FunctionId::IOCONTROL_COMPLETION), - }, - request_id: 876, - hresult: 0, - information: 4, - output_buffer_size: 4, - output_buffer: vec![1, 2, 3, 4], - }; - let de = round_trip!(en, IoControlCompletion); - assert_eq!(en, de); - - en.hresult = HRESULT_FROM_WIN32_ERROR_INSUFFICIENT_BUFFER; - en.output_buffer_size = 2; - en.information = 2; - en.output_buffer = vec![1, 2]; - let de = round_trip!(en, IoControlCompletion); - assert_eq!(en, de); - - en.hresult = 13124; - en.information = 89374; - en.output_buffer_size = 0; - en.output_buffer.clear(); - let de = round_trip!(en, IoControlCompletion); - assert_eq!(en, de); - } - - #[test] - fn urb_completion() { - let mut en = UrbCompletion { - header: SharedMsgHeader { - interface_id: InterfaceId(78435), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 234, - function_id: Some(FunctionId::URB_COMPLETION), - }, - req_id: RequestIdTransferInOut::try_from(234).unwrap(), - ts_urb_result: TsUrbResult { - header: TsUrbResultHeader { usbd_status: 0 }, - payload: TsUrbResultPayload::Isoch(TsUrbIsochTransferResult { - start_frame: 123, - error_count: 1, - iso_packet: vec![ - UsbdIsoPacketDesc { - offset: 0, - length: 2, - status: 0, - }, - UsbdIsoPacketDesc { - offset: 2, - length: 2, - status: -1, - }, - UsbdIsoPacketDesc { - offset: 4, - length: 2, - status: 0, - }, - ], - }), - }, - hresult: HRESULT_FROM_WIN32!(0u32), - output_buffer: vec![1, 2, 3, 4, 5, 6], - }; - - let de = round_trip!(en, UrbCompletion); - let mut buf = vec![0; en.ts_urb_result.payload.size()]; - en.ts_urb_result - .payload - .encode(&mut WriteCursor::new(&mut buf)) - .unwrap(); - assert_eq!(en, de); - - en.ts_urb_result.payload = TsUrbResultPayload::Raw(vec![]); // IO_CONTROL / INTERNAL_IO_CONTROL - let de = round_trip!(en, UrbCompletion); - assert_eq!(en, de); - } - - #[test] - fn urb_completion_no_data() { - let mut en = UrbCompletionNoData { - header: SharedMsgHeader { - interface_id: InterfaceId(78435), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 234, - function_id: Some(FunctionId::URB_COMPLETION_NO_DATA), - }, - req_id: RequestIdTransferInOut::try_from(234).unwrap(), - ts_urb_result: TsUrbResult { - header: TsUrbResultHeader { usbd_status: 0 }, - payload: TsUrbResultPayload::FrameNum(TsUrbGetCurrFrameNumResult { frame_number: 234 }), - }, - hresult: HRESULT_FROM_WIN32!(0u32), - output_buffer_size: 0, - }; - - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let mut src = ReadCursor::new(&buf); - let de = SharedMsgHeader::decode(&mut src) - .and_then(|header| ::decode(&mut src, header)) - .unwrap(); - - let mut buf = vec![0; en.ts_urb_result.payload.size()]; - en.ts_urb_result - .payload - .encode(&mut WriteCursor::new(&mut buf)) - .unwrap(); - en.ts_urb_result.payload = TsUrbResultPayload::Raw(buf); - assert_eq!(en, de); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs b/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs index d456445f9f..2cc22260ba 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs @@ -584,507 +584,3 @@ pub enum UsbdPipeType { /// Indicates that the pipe is an interrupt pipe. Interrupt = 0x3, } - -#[cfg(test)] -mod tests { - use alloc::vec; - - use super::*; - - extern crate std; - - #[test] - fn header() { - let en = TsUrbResultHeader { usbd_status: 234 }; - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbResultHeader::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - } - - #[test] - fn ts_usbd_pipe_info_result() { - let mut buf = vec![0; TsUsbdPipeInfoResult::FIXED_PART_SIZE]; - let mut en = TsUsbdPipeInfoResult { - max_packet_size: 1, - endpoint_address: 2, - interval: 3, - pipe_type: UsbdPipeType::Control, - pipe_handle: 4, - max_transfer_size: 5, - pipe_flags: 6, - }; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUsbdPipeInfoResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - - en.pipe_type = UsbdPipeType::Isochronous; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUsbdPipeInfoResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - - en.pipe_type = UsbdPipeType::Bulk; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUsbdPipeInfoResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - - en.pipe_type = UsbdPipeType::Interrupt; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUsbdPipeInfoResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - } - - #[test] - fn ts_usbd_interface_info_result() { - let en = TsUsbdInterfaceInfoResult { - interface_number: 0, - alternate_setting: 1, - class: 2, - sub_class: 3, - protocol: 4, - interface_handle: 5, - pipes: vec![ - TsUsbdPipeInfoResult { - max_packet_size: 6, - endpoint_address: 7, - interval: 8, - pipe_type: UsbdPipeType::Control, - pipe_handle: 9, - max_transfer_size: 10, - pipe_flags: 11, - }, - TsUsbdPipeInfoResult { - max_packet_size: 12, - endpoint_address: 13, - interval: 14, - pipe_type: UsbdPipeType::Isochronous, - pipe_handle: 15, - max_transfer_size: 16, - pipe_flags: 17, - }, - TsUsbdPipeInfoResult { - max_packet_size: 24, - endpoint_address: 25, - interval: 26, - pipe_type: UsbdPipeType::Bulk, - pipe_handle: 27, - max_transfer_size: 28, - pipe_flags: 29, - }, - TsUsbdPipeInfoResult { - max_packet_size: 30, - endpoint_address: 31, - interval: 32, - pipe_type: UsbdPipeType::Interrupt, - pipe_handle: 33, - max_transfer_size: 34, - pipe_flags: 35, - }, - ], - }; - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUsbdInterfaceInfoResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - } - - #[test] - fn ts_urb_select_config_result_payload() { - let en = TsUrbSelectConfigResult { - config_handle: 123, - interface: vec![ - TsUsbdInterfaceInfoResult { - interface_number: 0, - alternate_setting: 1, - class: 2, - sub_class: 3, - protocol: 4, - interface_handle: 5, - pipes: vec![ - TsUsbdPipeInfoResult { - max_packet_size: 6, - endpoint_address: 7, - interval: 8, - pipe_type: UsbdPipeType::Control, - pipe_handle: 9, - max_transfer_size: 10, - pipe_flags: 11, - }, - TsUsbdPipeInfoResult { - max_packet_size: 12, - endpoint_address: 13, - interval: 14, - pipe_type: UsbdPipeType::Isochronous, - pipe_handle: 15, - max_transfer_size: 16, - pipe_flags: 17, - }, - ], - }, - TsUsbdInterfaceInfoResult { - interface_number: 18, - alternate_setting: 19, - class: 20, - sub_class: 21, - protocol: 22, - interface_handle: 23, - pipes: vec![ - TsUsbdPipeInfoResult { - max_packet_size: 24, - endpoint_address: 25, - interval: 26, - pipe_type: UsbdPipeType::Bulk, - pipe_handle: 27, - max_transfer_size: 28, - pipe_flags: 29, - }, - TsUsbdPipeInfoResult { - max_packet_size: 30, - endpoint_address: 31, - interval: 32, - pipe_type: UsbdPipeType::Interrupt, - pipe_handle: 33, - max_transfer_size: 34, - pipe_flags: 35, - }, - ], - }, - ], - }; - - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbSelectConfigResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - - let en = TsUrbResultPayload::SelectConfig(en); - let mut buf2 = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf2)).unwrap(); - let de = TsUrbResultPayload::decode(&mut ReadCursor::new(&buf2[..en.size()])).unwrap(); - assert_eq!(de, TsUrbResultPayload::Raw(buf2)); - } - - #[test] - fn ts_urb_select_interface_result_payload() { - let en = TsUrbSelectInterfaceResult { - interface: TsUsbdInterfaceInfoResult { - interface_number: 0, - alternate_setting: 1, - class: 2, - sub_class: 3, - protocol: 4, - interface_handle: 5, - pipes: vec![ - TsUsbdPipeInfoResult { - max_packet_size: 6, - endpoint_address: 7, - interval: 8, - pipe_type: UsbdPipeType::Control, - pipe_handle: 9, - max_transfer_size: 10, - pipe_flags: 11, - }, - TsUsbdPipeInfoResult { - max_packet_size: 12, - endpoint_address: 13, - interval: 14, - pipe_type: UsbdPipeType::Isochronous, - pipe_handle: 15, - max_transfer_size: 16, - pipe_flags: 17, - }, - TsUsbdPipeInfoResult { - max_packet_size: 24, - endpoint_address: 25, - interval: 26, - pipe_type: UsbdPipeType::Bulk, - pipe_handle: 27, - max_transfer_size: 28, - pipe_flags: 29, - }, - TsUsbdPipeInfoResult { - max_packet_size: 30, - endpoint_address: 31, - interval: 32, - pipe_type: UsbdPipeType::Interrupt, - pipe_handle: 33, - max_transfer_size: 34, - pipe_flags: 35, - }, - ], - }, - }; - - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbSelectInterfaceResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - - let en = TsUrbResultPayload::SelectIface(en); - let mut buf2 = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf2)).unwrap(); - let de = TsUrbResultPayload::decode(&mut ReadCursor::new(&buf2[..en.size()])).unwrap(); - assert_eq!(de, TsUrbResultPayload::Raw(buf2)); - } - - #[test] - fn ts_urb_get_curr_frame_num_result_payload() { - let en = TsUrbGetCurrFrameNumResult { frame_number: 133 }; - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbGetCurrFrameNumResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - - let en = TsUrbResultPayload::FrameNum(en); - let mut buf2 = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf2)).unwrap(); - let de = TsUrbResultPayload::decode(&mut ReadCursor::new(&buf2[..en.size()])).unwrap(); - assert_eq!(de, TsUrbResultPayload::Raw(buf2)); - } - - #[test] - fn ts_urb_isoch_transfer_result_payload() { - let en = TsUrbIsochTransferResult { - start_frame: 123, - error_count: 1, - iso_packet: vec![ - UsbdIsoPacketDesc { - offset: 0, - length: 1024, - status: 0, - }, - UsbdIsoPacketDesc { - offset: 1024, - length: 1024, - status: -1, - }, - UsbdIsoPacketDesc { - offset: 2048, - length: 1024, - status: 0, - }, - ], - }; - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbIsochTransferResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en, de); - - let en = TsUrbResultPayload::Isoch(en); - let mut buf2 = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf2)).unwrap(); - let de = TsUrbResultPayload::decode(&mut ReadCursor::new(&buf2[..en.size()])).unwrap(); - assert_eq!(de, TsUrbResultPayload::Raw(buf2)); - } - - #[test] - fn ts_urb_result() { - let mut en = TsUrbResult { - header: TsUrbResultHeader { usbd_status: 12342 }, - payload: TsUrbResultPayload::Raw(vec![]), - }; - - en.payload = TsUrbResultPayload::SelectConfig(TsUrbSelectConfigResult { - config_handle: 8976, - interface: vec![ - TsUsbdInterfaceInfoResult { - interface_number: 0, - alternate_setting: 1, - class: 2, - sub_class: 3, - protocol: 4, - interface_handle: 5, - pipes: vec![ - TsUsbdPipeInfoResult { - max_packet_size: 6, - endpoint_address: 7, - interval: 8, - pipe_type: UsbdPipeType::Control, - pipe_handle: 9, - max_transfer_size: 10, - pipe_flags: 11, - }, - TsUsbdPipeInfoResult { - max_packet_size: 12, - endpoint_address: 13, - interval: 14, - pipe_type: UsbdPipeType::Isochronous, - pipe_handle: 15, - max_transfer_size: 16, - pipe_flags: 17, - }, - ], - }, - TsUsbdInterfaceInfoResult { - interface_number: 18, - alternate_setting: 19, - class: 20, - sub_class: 21, - protocol: 22, - interface_handle: 23, - pipes: vec![ - TsUsbdPipeInfoResult { - max_packet_size: 24, - endpoint_address: 25, - interval: 26, - pipe_type: UsbdPipeType::Bulk, - pipe_handle: 27, - max_transfer_size: 28, - pipe_flags: 29, - }, - TsUsbdPipeInfoResult { - max_packet_size: 30, - endpoint_address: 31, - interval: 32, - pipe_type: UsbdPipeType::Interrupt, - pipe_handle: 33, - max_transfer_size: 34, - pipe_flags: 35, - }, - ], - }, - ], - }); - - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en.size(), de.size()); - assert_eq!(en.payload.size(), de.payload.size()); - assert_eq!(en.header, de.header); - let TsUrbResultPayload::Raw(payload) = de.payload else { - unreachable!() - }; - let payload = TsUrbSelectConfigResult::decode(&mut ReadCursor::new(&payload)).unwrap(); - assert_eq!(en.payload, TsUrbResultPayload::SelectConfig(payload)); - - let mut buf2 = vec![0; en.payload.size()]; - en.payload.encode(&mut WriteCursor::new(&mut buf2)).unwrap(); - let de = TsUrbResultPayload::decode(&mut ReadCursor::new(&buf2[..en.payload.size()])).unwrap(); - assert_eq!(de, TsUrbResultPayload::Raw(buf2)); - - en.payload = TsUrbResultPayload::SelectIface(TsUrbSelectInterfaceResult { - interface: TsUsbdInterfaceInfoResult { - interface_number: 0, - alternate_setting: 1, - class: 2, - sub_class: 3, - protocol: 4, - interface_handle: 5, - pipes: vec![ - TsUsbdPipeInfoResult { - max_packet_size: 6, - endpoint_address: 7, - interval: 8, - pipe_type: UsbdPipeType::Control, - pipe_handle: 9, - max_transfer_size: 10, - pipe_flags: 11, - }, - TsUsbdPipeInfoResult { - max_packet_size: 12, - endpoint_address: 13, - interval: 14, - pipe_type: UsbdPipeType::Isochronous, - pipe_handle: 15, - max_transfer_size: 16, - pipe_flags: 17, - }, - TsUsbdPipeInfoResult { - max_packet_size: 24, - endpoint_address: 25, - interval: 26, - pipe_type: UsbdPipeType::Bulk, - pipe_handle: 27, - max_transfer_size: 28, - pipe_flags: 29, - }, - TsUsbdPipeInfoResult { - max_packet_size: 30, - endpoint_address: 31, - interval: 32, - pipe_type: UsbdPipeType::Interrupt, - pipe_handle: 33, - max_transfer_size: 34, - pipe_flags: 35, - }, - ], - }, - }); - - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en.size(), de.size()); - assert_eq!(en.payload.size(), de.payload.size()); - assert_eq!(en.header, de.header); - let TsUrbResultPayload::Raw(payload) = de.payload else { - unreachable!() - }; - let payload = TsUrbSelectInterfaceResult::decode(&mut ReadCursor::new(&payload)).unwrap(); - assert_eq!(en.payload, TsUrbResultPayload::SelectIface(payload)); - - let mut buf2 = vec![0; en.payload.size()]; - en.payload.encode(&mut WriteCursor::new(&mut buf2)).unwrap(); - let de = TsUrbResultPayload::decode(&mut ReadCursor::new(&buf2[..en.payload.size()])).unwrap(); - assert_eq!(de, TsUrbResultPayload::Raw(buf2)); - - en.payload = TsUrbResultPayload::FrameNum(TsUrbGetCurrFrameNumResult { frame_number: 133 }); - - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en.size(), de.size()); - assert_eq!(en.payload.size(), de.payload.size()); - assert_eq!(en.header, de.header); - let TsUrbResultPayload::Raw(payload) = de.payload else { - unreachable!() - }; - let payload = TsUrbGetCurrFrameNumResult::decode(&mut ReadCursor::new(&payload)).unwrap(); - assert_eq!(en.payload, TsUrbResultPayload::FrameNum(payload)); - - let mut buf2 = vec![0; en.payload.size()]; - en.payload.encode(&mut WriteCursor::new(&mut buf2)).unwrap(); - let de = TsUrbResultPayload::decode(&mut ReadCursor::new(&buf2[..en.payload.size()])).unwrap(); - assert_eq!(de, TsUrbResultPayload::Raw(buf2)); - - en.payload = TsUrbResultPayload::Isoch(TsUrbIsochTransferResult { - start_frame: 123, - error_count: 1, - iso_packet: vec![ - UsbdIsoPacketDesc { - offset: 0, - length: 1024, - status: 0, - }, - UsbdIsoPacketDesc { - offset: 1024, - length: 1024, - status: -1, - }, - UsbdIsoPacketDesc { - offset: 2048, - length: 1024, - status: 0, - }, - ], - }); - - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf)).unwrap(); - let de = TsUrbResult::decode(&mut ReadCursor::new(&buf)).unwrap(); - assert_eq!(en.size(), de.size()); - assert_eq!(en.payload.size(), de.payload.size()); - assert_eq!(en.header, de.header); - let TsUrbResultPayload::Raw(payload) = de.payload else { - unreachable!() - }; - let payload = TsUrbIsochTransferResult::decode(&mut ReadCursor::new(&payload)).unwrap(); - assert_eq!(en.payload, TsUrbResultPayload::Isoch(payload)); - - let mut buf2 = vec![0; en.payload.size()]; - en.payload.encode(&mut WriteCursor::new(&mut buf2)).unwrap(); - let de = TsUrbResultPayload::decode(&mut ReadCursor::new(&buf2[..en.payload.size()])).unwrap(); - assert_eq!(de, TsUrbResultPayload::Raw(buf2)); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/header.rs b/crates/ironrdp-rdpeusb/src/pdu/header.rs index 3faefdf467..6f1b3121c8 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/header.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/header.rs @@ -2,11 +2,8 @@ //! //! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a1004d0e-99e9-4968-894b-0b924ef2f125 -use alloc::format; - use ironrdp_core::{ - Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, - unsupported_value_err, + Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, }; #[cfg(doc)] @@ -43,33 +40,18 @@ impl From for u32 { } impl TryFrom for Mask { - type Error = MaskErr; + type Error = DecodeError; fn try_from(value: u8) -> Result { match value { 0x0 => Ok(Self::StreamIdNone), 0x1 => Ok(Self::StreamIdProxy), 0x2 => Ok(Self::StreamIdStub), - _ => Err(MaskErr(value)), + _ => Err(invalid_field_err!("try_from", "Mask", "invalid mask")), } } } -#[derive(Debug)] -pub struct MaskErr(u8); - -impl core::fmt::Display for MaskErr { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "is: {:#X}, should be one of: 0x2 (STREAM_ID_STUB), 0x1 (STREAM_ID_PROXY), 0x0 (STREAM_ID_NONE)", - self.0 - ) - } -} - -impl core::error::Error for MaskErr {} - /// Groups similar kinds of messages together. /// /// An interface is a "group" of similar kinds of messages. Some interfaces have default ID's @@ -121,13 +103,17 @@ impl InterfaceId { } impl TryFrom for InterfaceId { - type Error = InterfaceIdErr; + type Error = DecodeError; fn try_from(value: u32) -> Result { if value <= 0x3F_FF_FF_FF { Ok(InterfaceId(value)) } else { - Err(InterfaceIdErr(value)) + Err(invalid_field_err!( + "try_from", + "InterfaceId", + "InterfaceId greater than 30 bits" + )) } } } @@ -144,17 +130,6 @@ impl core::fmt::Display for InterfaceId { } } -#[derive(Debug)] -pub struct InterfaceIdErr(u32); - -impl core::fmt::Display for InterfaceIdErr { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "InterfaceId greater than 30 bits: {}", self.0) - } -} - -impl core::error::Error for InterfaceIdErr {} - /// Indicates a task/function to perform. /// /// Function ID's are defined for all interfaces: @@ -194,8 +169,8 @@ impl FunctionId { // // Needed for QI_REQ and QI_RSP // // /// Release the given interface ID. - // pub const RIMCALL_RELEASE: Self = Self(0x00000001); - // pub const RIMCALL_QUERYINTERFACE: Self = Self(0x00000002); + pub const RIMCALL_RELEASE: Self = Self(0x00000001); + pub const RIMCALL_QUERYINTERFACE: Self = Self(0x00000002); // -------------------- Exchange Capabilities Interface --------------------------------------- @@ -235,14 +210,13 @@ impl FunctionId { } impl TryFrom for FunctionId { - type Error = FunctionIdErr; + type Error = DecodeError; fn try_from(value: u32) -> Result { - // if matches!(value, 0x001 | 0x002 | 0x100..=0x107) { - if matches!(value, 0x100..=0x107) { + if matches!(value, 0x001 | 0x002 | 0x100..=0x107) { Ok(Self(value)) } else { - Err(FunctionIdErr::NotInRange(value)) + Err(invalid_field_err!("FunctionId", "invalid FunctionId")) } } } @@ -253,34 +227,6 @@ impl core::fmt::Display for FunctionId { } } -#[derive(Debug)] -pub enum FunctionIdErr { - NotInRange(u32), - InvalidForInterface(InterfaceId, FunctionId), - Missing, - NotAbsent, -} - -impl core::fmt::Display for FunctionIdErr { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - FunctionIdErr::NotInRange(value) => { - write!( - f, - "is: {value:#X}, should be one of: [0x100, 0x101, 0x102, 0x103, 0x104, 0x105, 0x106, 0x107]" - ) - } - FunctionIdErr::InvalidForInterface(i, value) => { - write!(f, "FunctionId {:#X} is invalid for the interface {i}", value.0) - } - FunctionIdErr::Missing => write!(f, "FunctionId is absent when it should be present"), - FunctionIdErr::NotAbsent => write!(f, "FunctionId is present when it should be absent"), - } - } -} - -impl core::error::Error for FunctionIdErr {} - /// [\[MS-RDPEUSB\] 2.2.1 Shared Message Header (SHARED_MSG_HEADER)][1]. /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/71cfb32c-ba15-4f95-9241-70f9df273909 @@ -329,13 +275,12 @@ impl Encode for SharedMsgHeader { impl Decode<'_> for SharedMsgHeader { fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { - ensure_size!(in: src, size: const { size_of::(/* InterfaceId, Mask */) + size_of::()} ); + ensure_size!(in: src, size: Self::SIZE_RSP ); let first32 = src.read_u32(); - let interface_id = InterfaceId::try_from(first32 & 0x3F_FF_FF_FF).expect("value clamped"); + let interface_id = InterfaceId::try_from(first32 & 0x3F_FF_FF_FF)?; #[expect(clippy::as_conversions)] - let mask = Mask::try_from((first32 >> 30) as u8) - .map_err(|source| unsupported_value_err!("Mask", format!("{}", source.0)))?; + let mask = Mask::try_from((first32 >> 30) as u8)?; let msg_id = src.read_u32(); @@ -343,14 +288,7 @@ impl Decode<'_> for SharedMsgHeader { Mask::StreamIdStub => None, Mask::StreamIdProxy => { ensure_size!(in: src, size: FunctionId::FIXED_PART_SIZE); - let id = FunctionId::try_from(src.read_u32()).map_err(|source| { - let value = match &source { - FunctionIdErr::NotInRange(value) => value, - _ => unreachable!("FunctionId::try_from only returns NotInRange error"), - }; - let e: DecodeError = unsupported_value_err!("FunctionId", format!("{value}")); - e.with_source(source) - })?; + let id = FunctionId::try_from(src.read_u32())?; Some(id) } Mask::StreamIdNone => { @@ -368,42 +306,3 @@ impl Decode<'_> for SharedMsgHeader { }) } } - -#[cfg(test)] -mod tests { - use alloc::vec::Vec; - - use super::*; - - #[test] - fn req() { - let mut wire = Vec::from([0; SharedMsgHeader::SIZE_REQ]); - let mut dst = WriteCursor::new(&mut wire); - let header_en = SharedMsgHeader { - interface_id: InterfaceId(234), - mask: Mask::StreamIdProxy, - msg_id: 6767, - function_id: Some(FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST), - }; - header_en.encode(&mut dst).unwrap(); - let mut src = ReadCursor::new(&wire); - let header_de = SharedMsgHeader::decode(&mut src).unwrap(); - assert_eq!(header_en, header_de); - } - - #[test] - fn rsp() { - let mut wire = Vec::from([0; SharedMsgHeader::SIZE_RSP]); - let mut dst = WriteCursor::new(&mut wire); - let header_en = SharedMsgHeader { - interface_id: InterfaceId(234), - mask: Mask::StreamIdStub, - msg_id: 6767, - function_id: None, - }; - header_en.encode(&mut dst).unwrap(); - let mut src = ReadCursor::new(&wire); - let header_de = SharedMsgHeader::decode(&mut src).unwrap(); - assert_eq!(header_en, header_de); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/mod.rs index 92ec6541f2..715fb3d2bd 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/mod.rs @@ -4,13 +4,11 @@ //! //! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a1004d0e-99e9-4968-894b-0b924ef2f125 -use ironrdp_core::{ - Decode as _, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, invalid_field_err, -}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, invalid_field_err}; use crate::pdu::caps::{RimExchangeCapabilityRequest, RimExchangeCapabilityResponse}; use crate::pdu::completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}; -use crate::pdu::header::{FunctionId, FunctionIdErr, InterfaceId, SharedMsgHeader}; +use crate::pdu::header::{FunctionId, InterfaceId, Mask, SharedMsgHeader}; use crate::pdu::notify::ChannelCreated; use crate::pdu::sink::{AddDevice, AddVirtualChannel}; use crate::pdu::usb_dev::{ @@ -40,53 +38,63 @@ pub enum UrbdrcServerPdu { Retract(RetractDevice), } -impl UrbdrcServerPdu { - pub fn decode(src: &mut ReadCursor<'_>, usb_device_s: I) -> DecodeResult - where - I: IntoIterator>, - { +impl Decode<'_> for UrbdrcServerPdu { + // TODO: QI_RSP + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { let header = SharedMsgHeader::decode(src)?; - let f_id = header.function_id.ok_or_else(|| { - let e: DecodeError = invalid_field_err!("SHARED_MSG_HEADER::FunctionId", "is absent"); - e.with_source(FunctionIdErr::Missing) - })?; + let f_id = header + .function_id + .ok_or_else(|| invalid_field_err!("SHARED_MSG_HEADER::FunctionId", "is absent"))?; match header.interface_id { - InterfaceId::CAPABILITIES => RimExchangeCapabilityRequest::decode(src, header).map(Self::Caps), + InterfaceId::CAPABILITIES => { + if f_id == FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST && header.mask == Mask::StreamIdNone { + RimExchangeCapabilityRequest::decode(src, header).map(Self::Caps) + } else { + Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid RIM_EXCHANGE_CAPABILITY_REQUEST header" + )) + } + } InterfaceId::NOTIFY_CLIENT => { - if f_id == FunctionId::CHANNEL_CREATED { + if f_id == FunctionId::CHANNEL_CREATED && header.mask == Mask::StreamIdProxy { ChannelCreated::decode(src, header).map(Self::ChanCreated) } else { - let e: DecodeError = - invalid_field_err!("CHANNEL_CREATED::SHARED_MSG_HEADER::FunctionId", "is not: 0x100"); - Err(e.with_source(FunctionIdErr::InvalidForInterface(InterfaceId::NOTIFY_CLIENT, f_id))) + Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid CHANNEL_CREATED header" + )) } } - id if usb_device_s.into_iter().any(|iface| iface == id) => match f_id { - FunctionId::CANCEL_REQUEST => CancelRequest::decode(src, header).map(Self::CancelReq), - FunctionId::REGISTER_REQUEST_CALLBACK => { - RegisterRequestCallback::decode(src, header).map(Self::RegReqCb) + InterfaceId::NOTIFY_SERVER | InterfaceId::DEVICE_SINK => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "reserved interface ID is not valid for server-to-client messages" + )), + _udev_iface => { + if header.mask != Mask::StreamIdProxy { + return Err(invalid_field_err!( + "SHARED_MSG_HEADER::Mask", + "is not 0x1 (STREAM_ID_PROXY)" + )); } - FunctionId::IO_CONTROL => IoControl::decode(src, header).map(Self::IoCtl), - FunctionId::INTERNAL_IO_CONTROL => InternalIoControl::decode(src, header).map(Self::InternalIoCtl), - FunctionId::QUERY_DEVICE_TEXT => QueryDeviceText::decode(src, header).map(Self::DevText), - FunctionId::TRANSFER_IN_REQUEST => TransferInRequest::decode(src, header).map(Self::TransferIn), - FunctionId::TRANSFER_OUT_REQUEST => TransferOutRequest::decode(src, header).map(Self::TransferOut), - FunctionId::RETRACT_DEVICE => RetractDevice::decode(src, header).map(Self::Retract), - _ => { - let e: DecodeError = invalid_field_err!( - "SHARED_MSG_HEADER::FunctionId (USB Devices Interface)", - "is not one of: 0x100 (CANCEL_REQUEST), 0x101 (REGISTER_REQUEST_CALLBACK), \ - 0x102 (IO_CONTROL), 0x103 (INTERNAL_IO_CONTROL), 0x104 (QUERY_DEVICE_TEXT), \ - 0x105 (TRANSFER_IN_REQUEST), 0x106 (TRANSFER_OUT_REQUEST), 0x107 (RETRACT_DEVICE)" - ); - Err(e.with_source(FunctionIdErr::InvalidForInterface(id, f_id))) + match f_id { + FunctionId::CANCEL_REQUEST => CancelRequest::decode(src, header).map(Self::CancelReq), + FunctionId::REGISTER_REQUEST_CALLBACK => { + RegisterRequestCallback::decode(src, header).map(Self::RegReqCb) + } + FunctionId::IO_CONTROL => IoControl::decode(src, header).map(Self::IoCtl), + FunctionId::INTERNAL_IO_CONTROL => InternalIoControl::decode(src, header).map(Self::InternalIoCtl), + FunctionId::QUERY_DEVICE_TEXT => QueryDeviceText::decode(src, header).map(Self::DevText), + FunctionId::TRANSFER_IN_REQUEST => TransferInRequest::decode(src, header).map(Self::TransferIn), + FunctionId::TRANSFER_OUT_REQUEST => TransferOutRequest::decode(src, header).map(Self::TransferOut), + FunctionId::RETRACT_DEVICE => RetractDevice::decode(src, header).map(Self::Retract), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER::FunctionId", + "unsupported function id for USB device interface" + )), } - }, - _ => Err(invalid_field_err!( - "SHARED_MSG_HEADER::InterfaceId", - "server sent message on an interface that is currently closed, or not supposed to be used by the server" - )), + } } } } @@ -135,74 +143,60 @@ pub enum UrbdrcClientPdu { UrbCompNoData(UrbCompletionNoData), } -impl UrbdrcClientPdu { - pub fn decode(src: &mut ReadCursor<'_>, usb_dev_s: I, completion_s: I) -> DecodeResult - where - I: IntoIterator>, - { +impl Decode<'_> for UrbdrcClientPdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { let header = SharedMsgHeader::decode(src)?; match header.interface_id { - InterfaceId::CAPABILITIES => RimExchangeCapabilityResponse::decode(src, header).map(Self::Caps), - InterfaceId::DEVICE_SINK => match header.function_id { - Some(FunctionId::ADD_VIRTUAL_CHANNEL) => AddVirtualChannel::decode(src, header).map(Self::AddChan), - Some(FunctionId::ADD_DEVICE) => AddDevice::decode(src, header).map(Self::AddDev), - Some(f_id) => { - let e: DecodeError = invalid_field_err!( - "SHARED_MSG_HEADER::FunctionId (Device Sink)", - "is not one of: 0x100 (ADD_VIRTUAL_CHANNEL), 0x101 (ADD_DEVICE)" - ); - Err(e.with_source(FunctionIdErr::InvalidForInterface(InterfaceId::DEVICE_SINK, f_id))) + InterfaceId::CAPABILITIES => { + if header.function_id.is_none() && header.mask == Mask::StreamIdNone { + RimExchangeCapabilityResponse::decode(src, header).map(Self::Caps) + } else { + Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid RIM_EXCHANGE_CAPABILITY_RESPONSE header" + )) } - None => { - let e: DecodeError = invalid_field_err!("SHARED_MSG_HEADER::FunctionId (Device Sink)", "is absent"); - Err(e.with_source(FunctionIdErr::Missing)) + } + InterfaceId::DEVICE_SINK => match (header.function_id, header.mask) { + (Some(FunctionId::ADD_VIRTUAL_CHANNEL), Mask::StreamIdProxy) => { + AddVirtualChannel::decode(src, header).map(Self::AddChan) } + (Some(FunctionId::ADD_DEVICE), Mask::StreamIdProxy) => AddDevice::decode(src, header).map(Self::AddDev), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid Device Sink interface header" + )), }, InterfaceId::NOTIFY_SERVER => { - const FIELD: &str = "CHANNEL_CREATED::SHARED_MSG_HEADER::FunctionId (Device Sink)"; - match header.function_id { - Some(FunctionId::CHANNEL_CREATED) => ChannelCreated::decode(src, header).map(Self::ChanCreated), - Some(f_id) => { - let e: DecodeError = invalid_field_err!(FIELD, "is not: 0x100"); - Err(e.with_source(FunctionIdErr::InvalidForInterface(InterfaceId::NOTIFY_SERVER, f_id))) - } - None => { - let e: DecodeError = invalid_field_err!(FIELD, "is absent"); - Err(e.with_source(FunctionIdErr::Missing)) - } + if header.function_id == Some(FunctionId::CHANNEL_CREATED) && header.mask == Mask::StreamIdProxy { + ChannelCreated::decode(src, header).map(Self::ChanCreated) + } else { + Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid CHANNEL_CREATED header" + )) } } - id if usb_dev_s.into_iter().any(|iface| iface == id) => match header.function_id { - Some(_) => { - let e: DecodeError = - invalid_field_err!("QUERY_DEVICE_TEXT_RSP::SHARED_MSG_HEADER::FunctionId", "is not absent"); - Err(e.with_source(FunctionIdErr::NotAbsent)) - } - None => QueryDeviceTextRsp::decode(src, header).map(Self::DevTextRsp), - }, - id if completion_s.into_iter().any(|iface| iface == id) => match header.function_id { - Some(FunctionId::IOCONTROL_COMPLETION) => IoControlCompletion::decode(src, header).map(Self::IoctlComp), - Some(FunctionId::URB_COMPLETION) => UrbCompletion::decode(src, header).map(Self::UrbComp), - Some(FunctionId::URB_COMPLETION_NO_DATA) => { - UrbCompletionNoData::decode(src, header).map(Self::UrbCompNoData) + InterfaceId::NOTIFY_CLIENT => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "reserved interface ID is not valid for client-to-server messages" + )), + _id => match (header.function_id, header.mask) { + (None, Mask::StreamIdStub) => QueryDeviceTextRsp::decode(src, header).map(Self::DevTextRsp), + (Some(FunctionId::IOCONTROL_COMPLETION), Mask::StreamIdProxy) => { + IoControlCompletion::decode(src, header).map(Self::IoctlComp) } - Some(f) => { - let e: DecodeError = invalid_field_err!( - "SHARED_MSG_HEADER::FunctionId (Request Completion)", - "is not one of: 0x100 (IOCONTROL_COMPLETION), 0x101 (URB_COMPLETION), 0x102 (URB_COMPLETION_NO_DATA)" - ); - Err(e.with_source(FunctionIdErr::InvalidForInterface(id, f))) + (Some(FunctionId::URB_COMPLETION), Mask::StreamIdProxy) => { + UrbCompletion::decode(src, header).map(Self::UrbComp) } - None => { - let e: DecodeError = - invalid_field_err!("SHARED_MSG_HEADER::FunctionId (Request Completion)", "is missing"); - Err(e.with_source(FunctionIdErr::Missing)) + (Some(FunctionId::URB_COMPLETION_NO_DATA), Mask::StreamIdProxy) => { + UrbCompletionNoData::decode(src, header).map(Self::UrbCompNoData) } + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER::InterfaceId", + "unknown interface id" + )), }, - _ => Err(invalid_field_err!( - "SHARED_MSG_HEADER::InterfaceId", - "client sent message on an interface that is currently closed, or not supposed to be used by the client" - )), } } } diff --git a/crates/ironrdp-rdpeusb/src/pdu/notify.rs b/crates/ironrdp-rdpeusb/src/pdu/notify.rs index 3154667104..a9ee7729bc 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/notify.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/notify.rs @@ -9,31 +9,11 @@ use alloc::format; use ironrdp_core::{ - DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, unsupported_value_err, }; -use crate::pdu::header::{FunctionId, InterfaceId, MessageId, SharedMsgHeader}; - -#[derive(Debug)] -pub enum ChannelCreatedErr { - MajorVersion(u32), - MinorVersion(u32), - Capabilities(u32), -} - -impl core::fmt::Display for ChannelCreatedErr { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let (field, is, expected) = match self { - Self::MajorVersion(value) => ("MajorVersion", value, 1), - Self::MinorVersion(value) => ("MinorVersion", value, 0), - Self::Capabilities(value) => ("Capabilities", value, 0), - }; - write!(f, "field {field} is: {is:#X}, should be: {expected}") - } -} - -impl core::error::Error for ChannelCreatedErr {} +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; /// [\[MS-RDPEUSB\] 2.2.5.1 Channel Created Message (CHANNEL_CREATED)][1] packet. /// @@ -42,9 +22,16 @@ impl core::error::Error for ChannelCreatedErr {} /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/e2859c23-acda-47d4-a2fc-9e7415e4b8d6 #[doc(alias = "CHANNEL_CREATED")] -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub struct ChannelCreated { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub direction: Direction, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Direction { + ToServer, + ToClient, } impl ChannelCreated { @@ -65,67 +52,51 @@ impl ChannelCreated { #[doc(alias = "Capabilities")] pub const CAPS: u32 = 0; - pub fn to_client(msg_id: MessageId) -> Self { - Self { - header: SharedMsgHeader { - interface_id: InterfaceId::NOTIFY_CLIENT, - mask: super::header::Mask::StreamIdProxy, - msg_id, - function_id: Some(FunctionId::CHANNEL_CREATED), + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: if let Direction::ToServer = self.direction { + InterfaceId::NOTIFY_SERVER + } else { + InterfaceId::NOTIFY_CLIENT }, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::CHANNEL_CREATED), } } - pub fn to_server(msg_id: MessageId) -> Self { - Self { - header: SharedMsgHeader { - interface_id: InterfaceId::NOTIFY_SERVER, - mask: super::header::Mask::StreamIdProxy, - msg_id, - function_id: Some(FunctionId::CHANNEL_CREATED), - }, - } - } - - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); let major = src.read_u32(); if major != Self::MAJOR_VER { - let e: DecodeError = unsupported_value_err!("MajorVersion", format!("{major}")); - return Err(e.with_source(ChannelCreatedErr::MajorVersion(major))); + return Err(unsupported_value_err!("MajorVersion", format!("{major}"))); } let minor = src.read_u32(); if minor != Self::MINOR_VER { - let e: DecodeError = unsupported_value_err!("MinorVersion", format!("{minor}")); - return Err(e.with_source(ChannelCreatedErr::MinorVersion(minor))); + return Err(unsupported_value_err!("MinorVersion", format!("{minor}"))); } let capabilities = src.read_u32(); if capabilities != Self::CAPS { - let e: DecodeError = unsupported_value_err!("Capabilities", format!("{capabilities}")); - return Err(e.with_source(ChannelCreatedErr::Capabilities(capabilities))); + return Err(unsupported_value_err!("Capabilities", format!("{capabilities}"))); } - Ok(Self { header }) + Ok(Self { + msg_id: header.msg_id, + direction: match header.interface_id { + InterfaceId::NOTIFY_CLIENT => Direction::ToClient, + InterfaceId::NOTIFY_SERVER => Direction::ToServer, + _ => unreachable!("dispatcher must filter interface_id to NOTIFY_CLIENT/NOTIFY_SERVER"), + }, + }) } } impl Encode for ChannelCreated { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - // ensure_function_id!( - // self.header, - // "CHANNEL_CREATED", - // FunctionId::CHANNEL_CREATED, - // "0x100 (CHANNEL_CREATED)" - // ); - // if self.header.interface_id != InterfaceId::NOTIFY_CLIENT - // && self.header.interface_id != InterfaceId::NOTIFY_SERVER - // { - // return Err(invalid_field_err!("CHANNEL_CREATED::Header::InterfaceId", "is not 0x1")); - // } ensure_fixed_part_size!(in: dst); - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(Self::MAJOR_VER); dst.write_u32(Self::MINOR_VER); @@ -142,36 +113,3 @@ impl Encode for ChannelCreated { Self::FIXED_PART_SIZE } } - -#[cfg(test)] -mod tests { - use ironrdp_core::{Decode as _, WriteBuf, encode_buf}; - - use super::*; - - #[test] - fn to_client() { - let to_client_en = ChannelCreated::to_client(1290); - let mut buf = WriteBuf::new(); - let en_size = encode_buf(&to_client_en, &mut buf).unwrap(); - assert_eq!(en_size, to_client_en.size()); - - let mut buf = ReadCursor::new(buf.filled()); - let header_de = SharedMsgHeader::decode(&mut buf).unwrap(); - let to_client_de = ChannelCreated::decode(&mut buf, header_de).unwrap(); - assert_eq!(to_client_en, to_client_de); - } - - #[test] - fn to_server() { - let to_client_en = ChannelCreated::to_server(1290); - let mut buf = WriteBuf::new(); - let en_size = encode_buf(&to_client_en, &mut buf).unwrap(); - assert_eq!(en_size, to_client_en.size()); - - let mut src = ReadCursor::new(buf.filled()); - let header_de = SharedMsgHeader::decode(&mut src).unwrap(); - let to_client_de = ChannelCreated::decode(&mut src, header_de).unwrap(); - assert_eq!(to_client_en, to_client_de); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/sink.rs b/crates/ironrdp-rdpeusb/src/pdu/sink.rs index fbe71a6cbf..0f092c0ccf 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/sink.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/sink.rs @@ -15,7 +15,7 @@ use ironrdp_pdu::utils::strict_sum; use ironrdp_str::multi_sz::MultiSzString; use ironrdp_str::prefixed::Cch32String; -use crate::pdu::header::{FunctionId, InterfaceId, MessageId, SharedMsgHeader}; +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; /// [\[MS-RDPEUSB\] 2.2.4.1 Add Virtual Channel Message (ADD_VIRTUAL_CHANNEL)][1] packet. /// @@ -25,38 +25,29 @@ use crate::pdu::header::{FunctionId, InterfaceId, MessageId, SharedMsgHeader}; #[doc(alias = "ADD_VIRTUAL_CHANNEL")] #[derive(Debug, PartialEq)] pub struct AddVirtualChannel { - pub header: SharedMsgHeader, + pub msg_id: MessageId, } impl AddVirtualChannel { - pub const FIZED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ; - - // pub const FUNCTION_ID: FunctionId = FunctionId::ADD_VIRTUAL_CHANNEL; - // - // pub const INTERFACE_ID: InterfaceId = InterfaceId(0x1); - - pub fn new(msg_id: MessageId) -> Self { - Self { - header: SharedMsgHeader { - interface_id: InterfaceId::DEVICE_SINK, - mask: super::header::Mask::StreamIdProxy, - msg_id, - function_id: Some(FunctionId::ADD_VIRTUAL_CHANNEL), - }, + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: InterfaceId::DEVICE_SINK, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::ADD_VIRTUAL_CHANNEL), } } - pub fn decode(_: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { - Ok(Self { header }) + pub(crate) fn decode(_: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + Ok(Self { msg_id: header.msg_id }) } } impl Encode for AddVirtualChannel { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - // ensure_interface_id!(self.header, Self::INTERFACE_ID, "ADD_VIRTUAL_CHANNEL", "0x100"); - // ensure_mask!(self.header, Mask::StreamIdProxy, "ADD_VIRTUAL_CHANNEL", "0x1"); - // ensure_function_id!(self.header, Self::FUNCTION_ID, "ADD_VIRTUAL_CHANNEL", "0x100"); - self.header.encode(dst) + self.header().encode(dst) } fn name(&self) -> &'static str { @@ -64,7 +55,7 @@ impl Encode for AddVirtualChannel { } fn size(&self) -> usize { - Self::FIZED_PART_SIZE + Self::FIXED_PART_SIZE } } @@ -76,7 +67,7 @@ impl Encode for AddVirtualChannel { #[doc(alias = "ADD_DEVICE")] #[derive(Debug, PartialEq)] pub struct AddDevice { - pub header: SharedMsgHeader, + pub msg_id: MessageId, /// The (unique) interface ID to be used by request messages in the [USB Devices][1] interface. /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/034257d7-f7a8-4fe1-b8c2-87ac8dc4f50e @@ -91,17 +82,17 @@ pub struct AddDevice { impl AddDevice { pub const NUM_USB_DEVICE: u32 = 0x1; - pub fn header(msg_id: MessageId) -> SharedMsgHeader { + pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { interface_id: InterfaceId::DEVICE_SINK, - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, function_id: Some(FunctionId::ADD_DEVICE), } } - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { - ensure_size!(in: src, size: 4); // NumUsbDevice + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* NumUsbDevice */); let num_usb_device = src.read_u32(); if num_usb_device != 0x1 { return Err(unsupported_value_err!("NumUsbDevice", format!("{num_usb_device}"))); @@ -118,7 +109,7 @@ impl AddDevice { let device_instance_id = Cch32String::decode_owned(src)?; - ensure_size!(in: src, size: 4); // cchHwIds + ensure_size!(in: src, size: 4 /* cchHwIds */); let hw_ids = if src.peek_u32() != 0 { Some(MultiSzString::decode_owned(src)?) } else { @@ -126,7 +117,7 @@ impl AddDevice { None }; - ensure_size!(in: src, size: 4); // cchCompatIds + ensure_size!(in: src, size: 4 /* cchCompatIds */); let compat_ids = if src.peek_u32() != 0 { Some(MultiSzString::decode_owned(src)?) } else { @@ -138,7 +129,7 @@ impl AddDevice { let usb_device_caps = UsbDeviceCaps::decode(src)?; Ok(Self { - header, + msg_id: header.msg_id, usb_device, device_instance_id, hw_ids, @@ -153,13 +144,7 @@ impl Encode for AddDevice { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - // SharedMsgHeader { - // interface_id: InterfaceId::DEVICE_SINK, - // mask: Mask::StreamIdProxy, - // msg_id: self.msg_id, - // function_id: Some(FunctionId::ADD_DEVICE), - // } - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(Self::NUM_USB_DEVICE); dst.write_u32(self.usb_device.into()); @@ -370,53 +355,3 @@ impl TryFrom for NoAckIsochWriteJitterBufSizeInMs { } } } - -#[cfg(test)] -mod tests { - use alloc::vec; - - use ironrdp_core::{WriteBuf, encode_buf}; - - use super::*; - - #[test] - fn add_virtual_channel() { - let en = AddVirtualChannel::new(45451); - let mut buf = WriteBuf::new(); - let written = encode_buf(&en, &mut buf).unwrap(); - assert_eq!(written, en.size()); - - let mut src = ReadCursor::new(buf.filled()); - let header_de = SharedMsgHeader::decode(&mut src).unwrap(); - let de = AddVirtualChannel::decode(&mut src, header_de).unwrap(); - assert_eq!(en, de); - } - - #[test] - fn add_device() { - let en = AddDevice { - header: AddDevice::header(76567), - usb_device: InterfaceId(675), - device_instance_id: Cch32String::new(r"USB\VID_0123&PID_4567\1234567890ABCDEF"), - hw_ids: Some(MultiSzString::new([r"USB\VID_0781&PID_5581&REV_0100", r"USB\VID_0781&PID_5581"]).unwrap()), - compat_ids: Some(MultiSzString::new([r"USB\CLASS_08&SUBCLASS_06", r"USB\CLASS_08"]).unwrap()), - container_id: Cch32String::from_wire_units(vec![11, 12, 21, 31, 41, 42, 43, 44]), - usb_device_caps: UsbDeviceCaps { - usb_bus_iface_ver: UsbBusIfaceVer::V1, - usbdi_ver: UsbdiVer::V0x500, - supported_usb_ver: SupportedUsbVer::Usb11, - device_speed: DeviceSpeed::FullSpeed, - no_ack_isoch_write_jitter_buf_size: - NoAckIsochWriteJitterBufSizeInMs::TS_URB_ISOCH_TRANSFER_NOT_SUPPORTED, - }, - }; - let mut buf = WriteBuf::new(); - let written = encode_buf(&en, &mut buf).unwrap(); - assert_eq!(written, en.size()); - - let mut src = ReadCursor::new(buf.filled()); - let header_de = SharedMsgHeader::decode(&mut src).unwrap(); - let de = AddDevice::decode(&mut src, header_de).unwrap(); - assert_eq!(en, de); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs index 82c2f45950..3b6e9962c8 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs @@ -11,16 +11,14 @@ use ironrdp_core::{ DecodeError, DecodeOwned as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, unsupported_value_err, }; -use ironrdp_pdu::utils::strict_sum; use ironrdp_str::prefixed::Cch32String; -use crate::pdu::header::{InterfaceId, SharedMsgHeader}; +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; use crate::pdu::usb_dev::ts_urb::{TransferDirection, TsUrb}; use crate::pdu::utils::{HResult, RequestId, RequestIdIoctl}; #[cfg(doc)] use crate::pdu::{ completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}, - header::{FunctionId, Mask}, sink::AddDevice, }; @@ -34,27 +32,41 @@ pub mod ts_urb; #[doc(alias = "CANCEL_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct CancelRequest { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, pub req_id: RequestId, } impl CancelRequest { - const PAYLOAD_SIZE: usize = size_of::(); + const PAYLOAD_SIZE: usize = 4 /* RequestId */; - const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_REQ; + const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */ + Self::PAYLOAD_SIZE /* RequestId */; - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::CANCEL_REQUEST), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); let req_id = src.read_u32(); - Ok(Self { header, req_id }) + Ok(Self { + msg_id: header.msg_id, + udev_iface: header.interface_id, + req_id, + }) } } impl Encode for CancelRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(self.req_id); Ok(()) } @@ -78,16 +90,26 @@ impl Encode for CancelRequest { #[doc(alias = "REGISTER_REQUEST_CALLBACK")] #[derive(Debug, PartialEq, Clone)] pub struct RegisterRequestCallback { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, pub request_completion: Option, } impl RegisterRequestCallback { - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { - ensure_size!(in: src, size: size_of::()); + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::REGISTER_REQUEST_CALLBACK), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* NumRequestCompletion */); let request_completion = match src.read_u32() { 0x0 => None, - 0x1 => { + _ => { ensure_size!(in: src, size: InterfaceId::FIXED_PART_SIZE); let interface = InterfaceId::try_from(src.read_u32()).map_err(|source| { let e: DecodeError = @@ -96,15 +118,10 @@ impl RegisterRequestCallback { })?; Some(interface) } - _ => { - return Err(invalid_field_err!( - "REGISTER_REQUEST_CALLBACK::NumRequestCompletion", - "is not 0x0 or 0x1" - )); - } }; Ok(Self { - header, + msg_id: header.msg_id, + udev_iface: header.interface_id, request_completion, }) } @@ -113,7 +130,7 @@ impl RegisterRequestCallback { impl Encode for RegisterRequestCallback { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - self.header.encode(dst)?; + self.header().encode(dst)?; if let Some(request_completion) = self.request_completion { dst.write_u32(0x1); dst.write_u32(request_completion.into()); @@ -129,13 +146,8 @@ impl Encode for RegisterRequestCallback { } fn size(&self) -> usize { - const NUM_REQUEST_COMPLETION: usize = size_of::(); - let request_completion = match self.request_completion { - Some(_) => InterfaceId::FIXED_PART_SIZE, - None => 0, - }; - - strict_sum(&[SharedMsgHeader::SIZE_REQ + NUM_REQUEST_COMPLETION + request_completion]) + let request_completion_size = if self.request_completion.is_some() { 4 } else { 0 }; + SharedMsgHeader::SIZE_REQ + 4 + request_completion_size } } @@ -147,26 +159,29 @@ impl Encode for RegisterRequestCallback { #[doc(alias = "IO_CONTROL")] #[derive(Debug, PartialEq, Clone)] pub struct IoControl { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, pub ioctl_code: IoctlInternalUsb, - /// Should be empty. As of v20240423, all USB IO Control Code's ([MS-RDPEUSB] 2.2.12 USB IO - /// Control Code) used in the protocol require sending an empty input buffer. - /// - /// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/4f4574f0-9368-4708-8f98-06aa2f44e198 pub input_buffer: Vec, pub output_buffer_size: u32, pub req_id: RequestIdIoctl, } impl IoControl { - #[expect(clippy::identity_op)] - pub const PAYLOAD_SIZE: usize = IoctlInternalUsb::FIZED_PART_SIZE - + size_of::(/* InputBufferSize */) - + 0 /* InputBuffer */ - + size_of::(/* OutputBufferSize */) - + size_of::(/* RequestId */); - - pub const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_REQ; + /// Minimum payload size, assuming `InputBuffer` is empty. + pub const PAYLOAD_MIN_SIZE: usize = IoctlInternalUsb::FIXED_PART_SIZE // IoControlCode + + 4 // InputBufferSize + + 4 // OutputBufferSize + + 4; // RequestId + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::IO_CONTROL), + } + } pub fn check_output_buffer_size(&self) -> Result<(), &'static str> { match self.ioctl_code { @@ -194,8 +209,8 @@ impl IoControl { } } - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_MIN_SIZE); let ioctl_code = match src.read_u32() { 0x220_007 => IoctlInternalUsb::ResetPort, 0x220_013 => IoctlInternalUsb::GetPortStatus, @@ -206,18 +221,17 @@ impl IoControl { 0x220_424 => IoctlInternalUsb::GetControllerName, value => return Err(unsupported_value_err!("IoControlCode", format!("{value}"))), }; - if let size @ 1.. = src.read_u32(/* InputBufferSize */) { - return Err(unsupported_value_err!( - "IO_CONTROL::InputBufferSize", - format!("{size:#X}") - )); - } + let input_buffer_size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, + size: input_buffer_size /* InputBuffer */ + 4 /* OutputBufferSize */ + 4 /* RequestId */); + let input_buffer = src.read_slice(input_buffer_size).to_vec(); let output_buffer_size = src.read_u32(); let req_id = src.read_u32(); let io_control = Self { - header, + msg_id: header.msg_id, + udev_iface: header.interface_id, ioctl_code, - input_buffer: Vec::new(), + input_buffer, output_buffer_size, req_id, }; @@ -233,25 +247,15 @@ impl Encode for IoControl { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { self.check_output_buffer_size() .map_err(|reason| invalid_field_err!("IO_CONTROL::OutputBufferSize", reason))?; - ensure_fixed_part_size!(in: dst); - self.header.encode(dst)?; + ensure_size!(in: dst, size: self.size()); + self.header().encode(dst)?; #[expect(clippy::as_conversions)] dst.write_u32(self.ioctl_code as u32); - if !self.input_buffer.is_empty() { - return Err(invalid_field_err!("IO_CONTROL::InputBuffer", "is not empty")); - } - // dst.write_u32(0); // InputBufferSize dst.write_u32(self.input_buffer.len().try_into().map_err(|e| other_err!(source: e))?); // InputBufferSize dst.write_slice(&self.input_buffer); - // let output_buffer_size = match self.ioctl_code { - // IoctlInternalUsb::ResetPort | IoctlInternalUsb::CyclePort => 0, - // IoctlInternalUsb::GetPortStatus | IoctlInternalUsb::GetHubCount => 4, - // IoctlInternalUsb::GetHubName | IoctlInternalUsb::GetControllerName => self.output_buffer_size, - // IoctlInternalUsb::GetBusInfo => 16, - // }; dst.write_u32(self.output_buffer_size); dst.write_u32(self.req_id); @@ -263,7 +267,7 @@ impl Encode for IoControl { } fn size(&self) -> usize { - Self::FIXED_PART_SIZE + SharedMsgHeader::SIZE_REQ + Self::PAYLOAD_MIN_SIZE + self.input_buffer.len() } } @@ -356,7 +360,7 @@ pub enum IoctlInternalUsb { } impl IoctlInternalUsb { - pub const FIZED_PART_SIZE: usize = size_of::(); + pub const FIXED_PART_SIZE: usize = 4 /* IoControlCode */; } /// [\[MS-RDPEUSB\] 2.2.13 USB Internal IO Control Code][1]. @@ -390,7 +394,8 @@ const IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME: u32 = 0x00224000; #[doc(alias = "INTERNAL_IO_CONTROL")] #[derive(Debug, PartialEq, Clone)] pub struct InternalIoControl { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, // Should make adding new codes easier. pub ioctl_code: UsbInternalIoctlCode, /// As of **v20240423**, all codes used for this message require sending an empty input buffer. @@ -405,15 +410,24 @@ pub struct InternalIoControl { impl InternalIoControl { #[expect(clippy::identity_op, reason = "for developer documentation purposes?")] - pub const PAYLOAD_SIZE: usize = size_of::() // IoControlCode - + size_of::(/* InputBufferSize */) + pub const PAYLOAD_SIZE: usize = 4 // IoControlCode + + 4 // InputBufferSize + 0 // InputBuffer - + size_of::(/* OutputBufferSize */) - + size_of::(/* RequestId */); + + 4 // OutputBufferSize + + 4; // RequestId - pub const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_REQ; + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */ + Self::PAYLOAD_SIZE; - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::INTERNAL_IO_CONTROL), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); { @@ -444,7 +458,8 @@ impl InternalIoControl { let req_id = src.read_u32(); Ok(Self { - header, + msg_id: header.msg_id, + udev_iface: header.interface_id, ioctl_code: UsbInternalIoctlCode::IoctlTsusbgdIoctlUsbdiQueryBusTime, input_buffer: Vec::new(), output_buffer_size, @@ -457,7 +472,7 @@ impl Encode for InternalIoControl { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME); // IoControlCode dst.write_u32(0x0); // InputBufferSize dst.write_u32(0x4); // OutputBufferSize @@ -486,18 +501,28 @@ impl Encode for InternalIoControl { #[doc(alias = "QUERY_DEVICE_TEXT")] #[derive(Debug, PartialEq, Clone)] pub struct QueryDeviceText { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, pub text_type: DeviceTextType, // TODO: Find out if MS-LCID and USB language ID's are same pub locale_id: u32, } impl QueryDeviceText { - pub const PAYLOAD_SIZE: usize = size_of::() + size_of::(/* LocaleId */); + pub const PAYLOAD_SIZE: usize = 4 /* TextType */ + 4 /* LocaleId */; - pub const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_REQ; + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */ + Self::PAYLOAD_SIZE; - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::QUERY_DEVICE_TEXT), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); let text_type = match src.read_u32() { @@ -513,7 +538,8 @@ impl QueryDeviceText { let locale_id = src.read_u32(); Ok(Self { - header, + msg_id: header.msg_id, + udev_iface: header.interface_id, text_type, locale_id, }) @@ -524,7 +550,7 @@ impl Encode for QueryDeviceText { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - self.header.encode(dst)?; + self.header().encode(dst)?; #[expect(clippy::as_conversions)] dst.write_u32(self.text_type as u32); dst.write_u32(self.locale_id); @@ -563,20 +589,31 @@ pub enum DeviceTextType { #[doc(alias = "QUERY_DEVICE_TEXT_RSP")] #[derive(Debug, PartialEq, Clone)] pub struct QueryDeviceTextRsp { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, pub device_description: Cch32String, pub hresult: HResult, } impl QueryDeviceTextRsp { - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdStub, + msg_id: self.msg_id, + function_id: None, + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { let device_description = Cch32String::decode_owned(src)?; - ensure_size!(in: src, size: 4); // HResult + ensure_size!(in: src, size: 4 /* HResult */); let hresult = src.read_u32(); Ok(Self { - header, + msg_id: header.msg_id, + udev_iface: header.interface_id, device_description, hresult, }) @@ -587,7 +624,7 @@ impl Encode for QueryDeviceTextRsp { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - self.header.encode(dst)?; + self.header().encode(dst)?; self.device_description.encode(dst)?; dst.write_u32(self.hresult); @@ -600,7 +637,9 @@ impl Encode for QueryDeviceTextRsp { } fn size(&self) -> usize { - strict_sum(&[SharedMsgHeader::SIZE_RSP + self.device_description.size() + const { size_of::() }]) + SharedMsgHeader::SIZE_RSP /* Header */ + + self.device_description.size() // cchDeviceDescription + DeviceDescription + + 4 /* HResult */ } } @@ -630,12 +669,22 @@ impl Encode for QueryDeviceTextRsp { #[doc(alias = "TRANSFER_IN_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TransferInRequest { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, pub ts_urb: TsUrb, pub output_buffer_size: u32, } impl TransferInRequest { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::TRANSFER_IN_REQUEST), + } + } + pub fn check_output_buffer_size(&self) -> Result<(), &'static str> { use TsUrb::*; @@ -672,17 +721,18 @@ impl TransferInRequest { } } - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { - ensure_size!(in: src, size: 4); // CbTsUrb + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* CbTsUrb */); let cb_ts_urb = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; let ts_urb = TsUrb::decode(&mut ReadCursor::new(src.read_slice(cb_ts_urb)), TransferDirection::In)?; - ensure_size!(in: src, size: 4); + ensure_size!(in: src, size: 4 /* OutputBufferSize */); let output_buffer_size = src.read_u32(); let transfer_in_req = Self { - header, + msg_id: header.msg_id, + udev_iface: header.interface_id, ts_urb, output_buffer_size, }; @@ -701,7 +751,7 @@ impl Encode for TransferInRequest { .map_err(|reason| invalid_field_err!("TRANSFER_IN_REQUEST::OutputBufferSize", reason))?; ensure_size!(in: dst, size: self.size()); - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(self.ts_urb.size().try_into().map_err(|e| other_err!(source: e))?); self.ts_urb.encode(dst, TransferDirection::In)?; dst.write_u32(self.output_buffer_size); @@ -714,9 +764,10 @@ impl Encode for TransferInRequest { } fn size(&self) -> usize { - const CB_TS_URB: usize = size_of::(); - const OUTPUT_BUFFER_SIZE: usize = size_of::(); - SharedMsgHeader::SIZE_REQ + CB_TS_URB + self.ts_urb.size() + OUTPUT_BUFFER_SIZE + SharedMsgHeader::SIZE_REQ /* Header */ + + 4 /* CbTsUrb */ + + self.ts_urb.size() /* TsUrb */ + + 4 /* OutputBufferSize */ } } @@ -728,26 +779,37 @@ impl Encode for TransferInRequest { #[doc(alias = "TRANSFER_OUT_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TransferOutRequest { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, pub ts_urb: TsUrb, pub output_buffer: Vec, } impl TransferOutRequest { - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::TRANSFER_OUT_REQUEST), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { let ts_urb = { - ensure_size!(in: src, size: 4); // CbTsUrb + ensure_size!(in: src, size: 4 /* CbTsUrb */); let cb_ts_urb = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; let mut src = ReadCursor::new(src.read_slice(cb_ts_urb)); TsUrb::decode(&mut src, TransferDirection::Out)? }; - ensure_size!(in: src, size: 4); // OutputBufferSize + ensure_size!(in: src, size: 4 /* OutputBufferSize */); let output_buffer_size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; let output_buffer = src.read_slice(output_buffer_size).to_vec(); Ok(Self { - header, + msg_id: header.msg_id, + udev_iface: header.interface_id, ts_urb, output_buffer, }) @@ -758,7 +820,7 @@ impl Encode for TransferOutRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - self.header.encode(dst)?; + self.header().encode(dst)?; dst.write_u32(self.ts_urb.size().try_into().map_err(|e| other_err!(source: e))?); @@ -776,15 +838,11 @@ impl Encode for TransferOutRequest { } fn size(&self) -> usize { - SharedMsgHeader::SIZE_REQ - + const { - size_of::(/* CbTsUrb */) - } - + self.ts_urb.size() - + const { - size_of::(/* OutputBufferSize */) - } - + self.output_buffer.len() + SharedMsgHeader::SIZE_REQ /* Header */ + + 4 /* CbTsUrb */ + + self.ts_urb.size() /* TsUrb */ + + 4 /* OutputBufferSize */ + + self.output_buffer.len() /* OutputBuffer */ } } @@ -796,16 +854,26 @@ impl Encode for TransferOutRequest { #[doc(alias = "RETRACT_DEVICE")] #[derive(Debug, PartialEq, Clone)] pub struct RetractDevice { - pub header: SharedMsgHeader, + pub msg_id: MessageId, + pub udev_iface: InterfaceId, pub reason: UsbRetractReason, } impl RetractDevice { - pub const PAYLOAD_SIZE: usize = size_of::(); + pub const PAYLOAD_SIZE: usize = 4 /* Reason */; + + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */ + Self::PAYLOAD_SIZE; - pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ + Self::PAYLOAD_SIZE; + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + interface_id: self.udev_iface, + mask: Mask::StreamIdProxy, + msg_id: self.msg_id, + function_id: Some(FunctionId::RETRACT_DEVICE), + } + } - pub fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); let reason = src.read_u32(); @@ -815,7 +883,8 @@ impl RetractDevice { } Ok(Self { - header, + msg_id: header.msg_id, + udev_iface: header.interface_id, reason: UsbRetractReason::BlockedByPolicy, }) } @@ -824,7 +893,7 @@ impl RetractDevice { impl Encode for RetractDevice { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - self.header.encode(dst)?; + self.header().encode(dst)?; #[expect(clippy::as_conversions)] dst.write_u32(self.reason as u32); Ok(()) @@ -851,550 +920,3 @@ pub enum UsbRetractReason { /// server's (group) policy. BlockedByPolicy = 0x1, } - -#[cfg(test)] -mod tests { - use alloc::vec; - - use ironrdp_core::Decode as _; - - use super::*; - use crate::pdu::header::FunctionId; - use crate::pdu::usb_dev::ts_urb::utils::{ - SetupPacket, TsUrbHeader, TsUsbdInterfaceInfo, TsUsbdPipeInfo, UrbFunction, UsbConfigDesc, - }; - use crate::pdu::usb_dev::ts_urb::{ - TsUrbBulkOrInterruptTransfer, TsUrbControlDescRequest, TsUrbControlFeatRequest, TsUrbControlGetConfigRequest, - TsUrbControlGetInterfaceRequest, TsUrbControlGetStatusRequest, TsUrbControlTransfer, TsUrbControlTransferEx, - TsUrbControlVendorClassRequest, TsUrbGetCurrFrameNum, TsUrbIsochTransfer, TsUrbOsFeatDescRequest, - TsUrbPipeRequest, TsUrbSelectConfig, TsUrbSelectInterface, - }; - use crate::pdu::utils::{ - RequestIdTransferInOut, USBD_START_ISO_TRANSFER_ASAP, USBD_TRANSFER_DIRECTION_IN, USBD_TRANSFER_DIRECTION_OUT, - round_trip, - }; - - #[test] - fn cancel_req() { - let en = CancelRequest { - header: SharedMsgHeader { - interface_id: InterfaceId(123), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 345, - function_id: Some(FunctionId::CANCEL_REQUEST), - }, - req_id: 678, - }; - let de = round_trip!(en, CancelRequest); - assert_eq!(en, de); - } - - #[test] - fn reg_req_cb() { - let en = RegisterRequestCallback { - header: SharedMsgHeader { - interface_id: InterfaceId(234), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 123, - function_id: Some(FunctionId::REGISTER_REQUEST_CALLBACK), - }, - request_completion: Some(InterfaceId(765)), - }; - let de = round_trip!(en, RegisterRequestCallback); - assert_eq!(en, de); - } - - #[test] - fn io_control() { - let mut en = IoControl { - header: SharedMsgHeader { - interface_id: InterfaceId(623), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 675, - function_id: Some(FunctionId::IO_CONTROL), - }, - ioctl_code: IoctlInternalUsb::ResetPort, - input_buffer: vec![], - output_buffer_size: 0, - req_id: 78, - }; - let de = round_trip!(en, IoControl); - assert_eq!(en, de); - - (en.ioctl_code, en.output_buffer_size) = (IoctlInternalUsb::GetPortStatus, 4); - let de = round_trip!(en, IoControl); - assert_eq!(en, de); - - (en.ioctl_code, en.output_buffer_size) = (IoctlInternalUsb::GetHubCount, 4); - let de = round_trip!(en, IoControl); - assert_eq!(en, de); - - (en.ioctl_code, en.output_buffer_size) = (IoctlInternalUsb::CyclePort, 0); - let de = round_trip!(en, IoControl); - assert_eq!(en, de); - - (en.ioctl_code, en.output_buffer_size) = (IoctlInternalUsb::GetHubName, 123123); - let de = round_trip!(en, IoControl); - assert_eq!(en, de); - - (en.ioctl_code, en.output_buffer_size) = (IoctlInternalUsb::GetBusInfo, 16); - let de = round_trip!(en, IoControl); - assert_eq!(en, de); - - en.ioctl_code = IoctlInternalUsb::GetControllerName; - (en.ioctl_code, en.output_buffer_size) = (IoctlInternalUsb::GetControllerName, 53456); - let de = round_trip!(en, IoControl); - assert_eq!(en, de); - } - - #[test] - fn internal_io_control() { - let mut en = InternalIoControl { - header: SharedMsgHeader { - interface_id: InterfaceId(6754), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 34234, - function_id: Some(FunctionId::INTERNAL_IO_CONTROL), - }, - ioctl_code: UsbInternalIoctlCode::IoctlTsusbgdIoctlUsbdiQueryBusTime, - input_buffer: vec![1, 2, 3], - output_buffer_size: 1234, - req_id: 7865, - }; - - let de = round_trip!(en, InternalIoControl); - (en.input_buffer, en.output_buffer_size) = (vec![], 4); - assert_eq!(en, de); - } - - #[test] - fn query_device_text() { - let mut en = QueryDeviceText { - header: SharedMsgHeader { - interface_id: InterfaceId(234), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 1231, - function_id: Some(FunctionId::QUERY_DEVICE_TEXT), - }, - text_type: DeviceTextType::Description, - locale_id: 8734, - }; - let de = round_trip!(en, QueryDeviceText); - assert_eq!(en, de); - - en.text_type = DeviceTextType::LocationInformation; - let de = round_trip!(en, QueryDeviceText); - assert_eq!(en, de); - } - - #[test] - fn query_device_text_rsp() { - let en = QueryDeviceTextRsp { - header: SharedMsgHeader { - interface_id: InterfaceId(234), - mask: crate::pdu::header::Mask::StreamIdStub, - msg_id: 21341, - function_id: None, - }, - device_description: Cch32String::new("adasdasd"), - hresult: 13123, - }; - let de = round_trip!(en, QueryDeviceTextRsp); - assert_eq!(en, de); - } - - #[test] - fn transfer_in_req() { - let mut en = TransferInRequest { - header: SharedMsgHeader { - interface_id: InterfaceId(234), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 3123, - function_id: Some(FunctionId::TRANSFER_IN_REQUEST), - }, - ts_urb: TsUrb::SelectConfig(TsUrbSelectConfig { - header: TsUrbHeader { - func: UrbFunction::SelectConfiguration, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - usbd_ifaces: vec![ - TsUsbdInterfaceInfo { - interface_number: 1, - alternate_setting: 1, - ts_usbd_pipe_info: vec![ - TsUsbdPipeInfo { - max_packet_size: 12, - max_transfer_size: 34, - pipe_flags: 0, - }, - TsUsbdPipeInfo { - max_packet_size: 56, - max_transfer_size: 78, - pipe_flags: 1, - }, - ], - }, - TsUsbdInterfaceInfo { - interface_number: 1, - alternate_setting: 2, - ts_usbd_pipe_info: vec![ - TsUsbdPipeInfo { - max_packet_size: 13, - max_transfer_size: 35, - pipe_flags: 0, - }, - TsUsbdPipeInfo { - max_packet_size: 57, - max_transfer_size: 79, - pipe_flags: 1, - }, - ], - }, - ], - desc: Some(UsbConfigDesc { - length: 1, - descriptor_type: 2, - total_length: 3, - num_interfaces: 4, - configuration_value: 5, - configuration: 6, - attributes: 7, - max_power: 8, - }), - }), - output_buffer_size: 0, - }; - let de = round_trip!(en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::SelectIface(TsUrbSelectInterface { - header: TsUrbHeader { - func: UrbFunction::SelectInterface, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - config_handle: 4, - usbd_iface: TsUsbdInterfaceInfo { - interface_number: 1, - alternate_setting: 2, - ts_usbd_pipe_info: vec![ - TsUsbdPipeInfo { - max_packet_size: 13, - max_transfer_size: 35, - pipe_flags: 0, - }, - TsUsbdPipeInfo { - max_packet_size: 57, - max_transfer_size: 79, - pipe_flags: 1, - }, - ], - }, - }); - let de = round_trip!(en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::PipeReq(TsUrbPipeRequest { - header: TsUrbHeader { - func: UrbFunction::AbortPipe, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 213, - }); - let de = round_trip!(en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::GetCurFrameNum(TsUrbGetCurrFrameNum { - header: TsUrbHeader { - func: UrbFunction::GetCurrentFrameNumber, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - }); - let de = round_trip!(en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlTransfer(TsUrbControlTransfer { - header: TsUrbHeader { - func: UrbFunction::ControlTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe: 235, - transfer_flags: USBD_TRANSFER_DIRECTION_IN, - setup_packet: SetupPacket { - request_type: 1 << 7, - request: 23, - value: 76, - index: 12, - length: 34, - }, - }); - en.output_buffer_size = 1024; - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::BulkInterruptTransfer(TsUrbBulkOrInterruptTransfer { - header: TsUrbHeader { - func: UrbFunction::BulkOrInterruptTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 13, - transfer_flags: USBD_TRANSFER_DIRECTION_IN, - }); - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::IsochTransfer(TsUrbIsochTransfer { - header: TsUrbHeader { - func: UrbFunction::IsochTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 23, - transfer_flags: USBD_TRANSFER_DIRECTION_IN | USBD_START_ISO_TRANSFER_ASAP, - start_frame: 0, - error_count: 0, - iso_packet_offsets: vec![0, 1, 2], - }); - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlDescReq(TsUrbControlDescRequest { - header: TsUrbHeader { - func: UrbFunction::GetDescriptorFromDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - index: 2, - desc_type: 3, - lang_id: 4, - }); - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlFeatReq(TsUrbControlFeatRequest { - header: TsUrbHeader { - func: UrbFunction::SetFeatureToDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - feat_selector: 1, - index: 2, - }); - en.output_buffer_size = 0; - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlGetStatus(TsUrbControlGetStatusRequest { - header: TsUrbHeader { - func: UrbFunction::GetStatusFromDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - index: 234, - }); - en.output_buffer_size = 2; - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::VendorClassReq(TsUrbControlVendorClassRequest { - header: TsUrbHeader { - func: UrbFunction::VendorDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - transfer_flags: USBD_TRANSFER_DIRECTION_IN, - request: 1, - value: 2, - index: 3, - }); - en.output_buffer_size = 1024; - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlGetConfig(TsUrbControlGetConfigRequest { - header: TsUrbHeader { - func: UrbFunction::GetConfiguration, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - }); - en.output_buffer_size = 1; - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlGetIface(TsUrbControlGetInterfaceRequest { - header: TsUrbHeader { - func: UrbFunction::GetInterface, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - interface: 5, - }); - en.output_buffer_size = 1; - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::OsFeatDescReq(TsUrbOsFeatDescRequest { - header: TsUrbHeader { - func: UrbFunction::GetMsFeatureDescriptor, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - recipient: 0, - interface_number: 0, - ms_feat_desc_index: 213, - }); - en.output_buffer_size = 1024; - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlTransferEx(TsUrbControlTransferEx { - header: TsUrbHeader { - func: UrbFunction::ControlTransferEx, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe: 235, - transfer_flags: USBD_TRANSFER_DIRECTION_IN, - timeout: 12, - // We only care about transfer direction for tests (bmRequestType D7) - setup_packet: SetupPacket { - request_type: 1 << 7, - request: 23, - value: 76, - index: 12, - length: 34, - }, - }); - en.output_buffer_size = 1024; - let de = round_trip!(&en, TransferInRequest); - assert_eq!(en, de); - } - - #[test] - fn transfer_out_request() { - let mut en = TransferOutRequest { - header: SharedMsgHeader { - interface_id: InterfaceId(123), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 1312, - function_id: Some(FunctionId::TRANSFER_OUT_REQUEST), - }, - ts_urb: TsUrb::CtlTransfer(TsUrbControlTransfer { - header: TsUrbHeader { - func: UrbFunction::ControlTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe: 235, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT, - // We only care about transfer direction for tests (bmRequestType D7) - setup_packet: SetupPacket { - request_type: 0, - request: 23, - value: 76, - index: 12, - length: 34, - }, - }), - output_buffer: vec![1, 2, 3], - }; - let de = round_trip!(en, TransferOutRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::BulkInterruptTransfer(TsUrbBulkOrInterruptTransfer { - header: TsUrbHeader { - func: UrbFunction::BulkOrInterruptTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 13, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT, - }); - let de = round_trip!(en, TransferOutRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::IsochTransfer(TsUrbIsochTransfer { - header: TsUrbHeader { - func: UrbFunction::IsochTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 23, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT | USBD_START_ISO_TRANSFER_ASAP, - start_frame: 0, - error_count: 0, - iso_packet_offsets: vec![0, 1, 2], - }); - let de = round_trip!(en, TransferOutRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlDescReq(TsUrbControlDescRequest { - header: TsUrbHeader { - func: UrbFunction::SetDescriptorToDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - index: 2, - desc_type: 3, - lang_id: 4, - }); - let de = round_trip!(en, TransferOutRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::VendorClassReq(TsUrbControlVendorClassRequest { - header: TsUrbHeader { - func: UrbFunction::VendorDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT, - request: 10, - value: 11, - index: 12, - }); - let de = round_trip!(en, TransferOutRequest); - assert_eq!(en, de); - - en.ts_urb = TsUrb::CtlTransferEx(TsUrbControlTransferEx { - header: TsUrbHeader { - func: UrbFunction::ControlTransferEx, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe: 235, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT, - timeout: 234, - // We only care about transfer direction for tests (bmRequestType D7) - setup_packet: SetupPacket { - request_type: 0, - request: 23, - value: 76, - index: 12, - length: 34, - }, - }); - let de = round_trip!(en, TransferOutRequest); - assert_eq!(en, de); - } - - #[test] - fn retract_device() { - let en = RetractDevice { - header: SharedMsgHeader { - interface_id: InterfaceId(34), - mask: crate::pdu::header::Mask::StreamIdProxy, - msg_id: 123412, - function_id: Some(FunctionId::RETRACT_DEVICE), - }, - reason: UsbRetractReason::BlockedByPolicy, - }; - let de = round_trip!(en, RetractDevice); - assert_eq!(en, de); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs index e5d9bb7535..e12eb455aa 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs @@ -1502,748 +1502,3 @@ impl Encode for TsUrbControlTransferEx { Self::FIXED_PART_SIZE } } - -#[cfg(test)] -mod tests { - use alloc::vec; - - use TransferDirection::*; - use utils::TsUsbdPipeInfo; - - use super::*; - use crate::pdu::utils::{ - RequestIdTransferInOut, USBD_DEFAULT_PIPE_TRANSFER, USBD_START_ISO_TRANSFER_ASAP, USBD_TRANSFER_DIRECTION_OUT, - }; - - fn round_trip(en: &TsUrb, direction: TransferDirection) -> TsUrb { - let mut buf = vec![0; en.size()]; - en.encode(&mut WriteCursor::new(&mut buf), direction).unwrap(); - TsUrb::decode(&mut ReadCursor::new(&buf), direction).unwrap() - } - - #[test] - fn select_config_in() { - let en = TsUrb::SelectConfig(TsUrbSelectConfig { - header: TsUrbHeader { - func: UrbFunction::SelectConfiguration, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - usbd_ifaces: vec![ - TsUsbdInterfaceInfo { - interface_number: 1, - alternate_setting: 1, - ts_usbd_pipe_info: vec![ - TsUsbdPipeInfo { - max_packet_size: 12, - max_transfer_size: 34, - pipe_flags: 0, - }, - TsUsbdPipeInfo { - max_packet_size: 56, - max_transfer_size: 78, - pipe_flags: 1, - }, - ], - }, - TsUsbdInterfaceInfo { - interface_number: 1, - alternate_setting: 2, - ts_usbd_pipe_info: vec![ - TsUsbdPipeInfo { - max_packet_size: 13, - max_transfer_size: 35, - pipe_flags: 0, - }, - TsUsbdPipeInfo { - max_packet_size: 57, - max_transfer_size: 79, - pipe_flags: 1, - }, - ], - }, - ], - desc: Some(UsbConfigDesc { - length: 1, - descriptor_type: 2, - total_length: 3, - num_interfaces: 4, - configuration_value: 5, - configuration: 6, - attributes: 7, - max_power: 8, - }), - }); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn select_interface_in() { - let en = TsUrb::SelectIface(TsUrbSelectInterface { - header: TsUrbHeader { - func: UrbFunction::SelectInterface, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - config_handle: 4, - usbd_iface: TsUsbdInterfaceInfo { - interface_number: 1, - alternate_setting: 2, - ts_usbd_pipe_info: vec![ - TsUsbdPipeInfo { - max_packet_size: 13, - max_transfer_size: 35, - pipe_flags: 0, - }, - TsUsbdPipeInfo { - max_packet_size: 57, - max_transfer_size: 79, - pipe_flags: 1, - }, - ], - }, - }); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn pipe_req_in() { - let mut ts_urb = TsUrbPipeRequest { - header: TsUrbHeader { - func: UrbFunction::AbortPipe, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 213, - }; - - let en = TsUrb::PipeReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SyncResetPipeAndClearStall; - let en = TsUrb::PipeReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SyncResetPipe; - let en = TsUrb::PipeReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SyncClearStall; - let en = TsUrb::PipeReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::CloseStaticStreams; - let en = TsUrb::PipeReq(ts_urb); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn frame_num_in() { - let en = TsUrb::GetCurFrameNum(TsUrbGetCurrFrameNum { - header: TsUrbHeader { - func: UrbFunction::GetCurrentFrameNumber, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - }); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_transfer_in() { - let en = TsUrb::CtlTransfer(TsUrbControlTransfer { - header: TsUrbHeader { - func: UrbFunction::ControlTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe: 235, - transfer_flags: USBD_TRANSFER_DIRECTION_IN, - // We only care about transfer direction for tests (bmRequestType D7) - setup_packet: SetupPacket { - request_type: 1 << 7, - request: 23, - value: 76, - index: 12, - length: 34, - }, - }); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_transfer_out() { - let mut ts_urb = TsUrbControlTransfer { - header: TsUrbHeader { - func: UrbFunction::ControlTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe: 235, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT, - // We only care about transfer direction for tests (bmRequestType D7) - setup_packet: SetupPacket { - request_type: 0, - request: 23, - value: 76, - index: 12, - length: 34, - }, - }; - - let en = TsUrb::CtlTransfer(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.pipe = 0; - ts_urb.transfer_flags = USBD_TRANSFER_DIRECTION_OUT | USBD_DEFAULT_PIPE_TRANSFER; - ts_urb.header.no_ack = true; - - let en = TsUrb::CtlTransfer(ts_urb); - let de = round_trip(&en, Out); - assert_eq!(en, de); - } - - #[test] - fn bulk_or_interrupt_transfer_in() { - let mut ts_urb = TsUrbBulkOrInterruptTransfer { - header: TsUrbHeader { - func: UrbFunction::BulkOrInterruptTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 13, - transfer_flags: USBD_TRANSFER_DIRECTION_IN, - }; - - let en = TsUrb::BulkInterruptTransfer(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::BulkOrInterruptTransferUsingChainedMdl; - ts_urb.pipe_handle = 23; - - let en = TsUrb::BulkInterruptTransfer(ts_urb); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn bulk_or_interrupt_transfer_out() { - let mut ts_urb = TsUrbBulkOrInterruptTransfer { - header: TsUrbHeader { - func: UrbFunction::BulkOrInterruptTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 13, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT, - }; - - let en = TsUrb::BulkInterruptTransfer(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::BulkOrInterruptTransferUsingChainedMdl; - let en = TsUrb::BulkInterruptTransfer(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.no_ack = true; - - ts_urb.header.func = UrbFunction::BulkOrInterruptTransfer; - let en = TsUrb::BulkInterruptTransfer(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::BulkOrInterruptTransferUsingChainedMdl; - let en = TsUrb::BulkInterruptTransfer(ts_urb); - let de = round_trip(&en, Out); - assert_eq!(en, de); - } - - #[test] - fn isoch_transfer_in() { - let mut ts_urb = TsUrbIsochTransfer { - header: TsUrbHeader { - func: UrbFunction::IsochTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 23, - transfer_flags: USBD_TRANSFER_DIRECTION_IN | USBD_START_ISO_TRANSFER_ASAP, - start_frame: 0, - error_count: 0, - iso_packet_offsets: vec![0, 1, 2], - }; - - let en = TsUrb::IsochTransfer(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::IsochTransferUsingChainedMdl; - let en = TsUrb::IsochTransfer(ts_urb); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn isoch_transfer_out() { - let mut ts_urb = TsUrbIsochTransfer { - header: TsUrbHeader { - func: UrbFunction::IsochTransfer, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe_handle: 23, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT | USBD_START_ISO_TRANSFER_ASAP, - start_frame: 0, - error_count: 0, - iso_packet_offsets: vec![0, 1, 2], - }; - - let en = TsUrb::IsochTransfer(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::IsochTransferUsingChainedMdl; - let en = TsUrb::IsochTransfer(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.no_ack = true; - - ts_urb.header.func = UrbFunction::IsochTransfer; - let en = TsUrb::IsochTransfer(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::IsochTransferUsingChainedMdl; - let en = TsUrb::IsochTransfer(ts_urb); - let de = round_trip(&en, Out); - assert_eq!(en, de); - } - - #[test] - fn control_desc_req_in() { - let mut ts_urb = TsUrbControlDescRequest { - header: TsUrbHeader { - func: UrbFunction::GetDescriptorFromDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - index: 2, - desc_type: 3, - lang_id: 4, - }; - let en = TsUrb::CtlDescReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::GetDescriptorFromEndpoint; - let en = TsUrb::CtlDescReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::GetDescriptorFromInterface; - let en = TsUrb::CtlDescReq(ts_urb); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_desc_req_out() { - let mut ts_urb = TsUrbControlDescRequest { - header: TsUrbHeader { - func: UrbFunction::SetDescriptorToDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - index: 2, - desc_type: 3, - lang_id: 4, - }; - let en = TsUrb::CtlDescReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SetDescriptorToEndpoint; - let en = TsUrb::CtlDescReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SetDescriptorToInterface; - let en = TsUrb::CtlDescReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.no_ack = !ts_urb.header.no_ack; - - ts_urb.header.func = UrbFunction::SetDescriptorToDevice; - let en = TsUrb::CtlDescReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SetDescriptorToEndpoint; - let en = TsUrb::CtlDescReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SetDescriptorToInterface; - let en = TsUrb::CtlDescReq(ts_urb); - let de = round_trip(&en, Out); - assert_eq!(en, de); - } - - #[test] - fn control_feat_req_in() { - let mut ts_urb = TsUrbControlFeatRequest { - header: TsUrbHeader { - func: UrbFunction::SetFeatureToDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - feat_selector: 1, - index: 2, - }; - let en = TsUrb::CtlFeatReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SetFeatureToInterface; - let en = TsUrb::CtlFeatReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SetFeatureToEndpoint; - let en = TsUrb::CtlFeatReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::SetFeatureToOther; - let en = TsUrb::CtlFeatReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClearFeatureToDevice; - let en = TsUrb::CtlFeatReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClearFeatureToInterface; - let en = TsUrb::CtlFeatReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClearFeatureToEndpoint; - let en = TsUrb::CtlFeatReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClearFeatureToOther; - let en = TsUrb::CtlFeatReq(ts_urb); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_get_status_req_in() { - let mut ts_urb = TsUrbControlGetStatusRequest { - header: TsUrbHeader { - func: UrbFunction::GetStatusFromDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - index: 234, - }; - let en = TsUrb::CtlGetStatus(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::GetStatusFromInterface; - let en = TsUrb::CtlGetStatus(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::GetStatusFromEndpoint; - let en = TsUrb::CtlGetStatus(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::GetStatusFromOther; - let en = TsUrb::CtlGetStatus(ts_urb); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_vendor_or_class_req_in() { - let mut ts_urb = TsUrbControlVendorClassRequest { - header: TsUrbHeader { - func: UrbFunction::VendorDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - transfer_flags: USBD_TRANSFER_DIRECTION_IN, - request: 1, - value: 2, - index: 3, - }; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorInterface; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorEndpoint; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorOther; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassDevice; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassInterface; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassEndpoint; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassOther; - let en = TsUrb::VendorClassReq(ts_urb); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_vendor_or_class_req_out() { - let mut ts_urb = TsUrbControlVendorClassRequest { - header: TsUrbHeader { - func: UrbFunction::VendorDevice, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT, - request: 10, - value: 11, - index: 12, - }; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorInterface; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorEndpoint; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorOther; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassDevice; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassInterface; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassEndpoint; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassOther; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.no_ack = !ts_urb.header.no_ack; - - ts_urb.header.func = UrbFunction::VendorDevice; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorInterface; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorEndpoint; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::VendorOther; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassDevice; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassInterface; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassEndpoint; - let en = TsUrb::VendorClassReq(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.header.func = UrbFunction::ClassOther; - let en = TsUrb::VendorClassReq(ts_urb); - let de = round_trip(&en, Out); - assert_eq!(en, de); - } - - #[test] - fn control_get_config_req_in() { - let en = TsUrb::CtlGetConfig(TsUrbControlGetConfigRequest { - header: TsUrbHeader { - func: UrbFunction::GetConfiguration, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - }); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_get_iface_req_in() { - let en = TsUrb::CtlGetIface(TsUrbControlGetInterfaceRequest { - header: TsUrbHeader { - func: UrbFunction::GetInterface, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - interface: 5, - }); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn os_feat_desc_req_in() { - let mut ts_urb = TsUrbOsFeatDescRequest { - header: TsUrbHeader { - func: UrbFunction::GetMsFeatureDescriptor, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - recipient: 0, - interface_number: 0, - ms_feat_desc_index: 213, - }; - let en = TsUrb::OsFeatDescReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.recipient = 1; - ts_urb.interface_number = 1; - let en = TsUrb::OsFeatDescReq(ts_urb.clone()); - let de = round_trip(&en, In); - assert_eq!(en, de); - - ts_urb.recipient = 2; - ts_urb.interface_number = 1; - let en = TsUrb::OsFeatDescReq(ts_urb); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_transfer_ex_in() { - let en = TsUrb::CtlTransferEx(TsUrbControlTransferEx { - header: TsUrbHeader { - func: UrbFunction::ControlTransferEx, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe: 235, - transfer_flags: USBD_TRANSFER_DIRECTION_IN, - timeout: 12, - // We only care about transfer direction for tests (bmRequestType D7) - setup_packet: SetupPacket { - request_type: 1 << 7, - request: 23, - value: 76, - index: 12, - length: 34, - }, - }); - let de = round_trip(&en, In); - assert_eq!(en, de); - } - - #[test] - fn control_transfer_ex_out() { - let mut ts_urb = TsUrbControlTransferEx { - header: TsUrbHeader { - func: UrbFunction::ControlTransferEx, - req_id: RequestIdTransferInOut::try_from(3453).unwrap(), - no_ack: false, - }, - pipe: 235, - transfer_flags: USBD_TRANSFER_DIRECTION_OUT, - timeout: 234, - // We only care about transfer direction for tests (bmRequestType D7) - setup_packet: SetupPacket { - request_type: 0, - request: 23, - value: 76, - index: 12, - length: 34, - }, - }; - - let en = TsUrb::CtlTransferEx(ts_urb.clone()); - let de = round_trip(&en, Out); - assert_eq!(en, de); - - ts_urb.pipe = 0; - ts_urb.transfer_flags = USBD_TRANSFER_DIRECTION_OUT | USBD_DEFAULT_PIPE_TRANSFER; - ts_urb.header.no_ack = true; - - let en = TsUrb::CtlTransferEx(ts_urb); - let de = round_trip(&en, Out); - assert_eq!(en, de); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs index 4795e484db..ef9dca416f 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs @@ -782,115 +782,3 @@ impl Decode<'_> for SetupPacket { }) } } - -#[cfg(test)] -mod tests { - use alloc::vec; - - use ironrdp_core::{WriteBuf, encode_buf}; - - use super::*; - - #[test] - fn header() { - let en = TsUrbHeader { - func: UrbFunction::ControlTransfer, - req_id: RequestIdTransferInOut::try_from(34).unwrap(), - no_ack: true, - }; - - let mut buf = WriteBuf::new(); - let written = encode_buf(&en, &mut buf).unwrap(); - assert_eq!(written, en.size()); - - let mut src = ReadCursor::new(buf.filled()); - let de = TsUrbHeader::decode(&mut src).unwrap(); - assert_eq!(en, de); - } - - #[test] - fn setup_packet() { - let en = SetupPacket { - request_type: 1, - request: 2, - value: 3, - index: 4, - length: 5, - }; - - let mut buf = WriteBuf::new(); - let written = encode_buf(&en, &mut buf).unwrap(); - assert_eq!(written, en.size()); - - let mut src = ReadCursor::new(buf.filled()); - let de = SetupPacket::decode(&mut src).unwrap(); - assert_eq!(en, de); - } - - #[test] - fn usb_config_desc() { - let en = UsbConfigDesc { - length: 1, - descriptor_type: 2, - total_length: 3, - num_interfaces: 4, - configuration_value: 5, - configuration: 6, - attributes: 7, - max_power: 8, - }; - - let mut buf = WriteBuf::new(); - let written = encode_buf(&en, &mut buf).unwrap(); - assert_eq!(written, en.size()); - - let mut src = ReadCursor::new(buf.filled()); - let de = UsbConfigDesc::decode(&mut src).unwrap(); - assert_eq!(en, de); - } - - #[test] - fn ts_usbd_pipe_info() { - let en = TsUsbdPipeInfo { - max_packet_size: 5678, - max_transfer_size: 1234, - pipe_flags: 1, - }; - - let mut buf = WriteBuf::new(); - let written = encode_buf(&en, &mut buf).unwrap(); - assert_eq!(written, en.size()); - - let mut src = ReadCursor::new(buf.filled()); - let de = TsUsbdPipeInfo::decode(&mut src).unwrap(); - assert_eq!(en, de); - } - - #[test] - fn ts_usbd_interface_info() { - let en = TsUsbdInterfaceInfo { - interface_number: 2, - alternate_setting: 3, - ts_usbd_pipe_info: vec![ - TsUsbdPipeInfo { - max_packet_size: 12, - max_transfer_size: 34, - pipe_flags: 0, - }, - TsUsbdPipeInfo { - max_packet_size: 56, - max_transfer_size: 78, - pipe_flags: 1, - }, - ], - }; - - let mut buf = WriteBuf::new(); - let written = encode_buf(&en, &mut buf).unwrap(); - assert_eq!(written, en.size()); - - let mut src = ReadCursor::new(buf.filled()); - let de = TsUsbdInterfaceInfo::decode(&mut src).unwrap(); - assert_eq!(en, de); - } -} diff --git a/crates/ironrdp-rdpeusb/src/pdu/utils.rs b/crates/ironrdp-rdpeusb/src/pdu/utils.rs index 63e83ffcb4..3374bb85cb 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/utils.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/utils.rs @@ -34,15 +34,6 @@ pub type RequestIdIoctl = u32; /// Is set to request data from a device. To transfer data to a device, this flag **MUST** be clear. pub(crate) const USBD_TRANSFER_DIRECTION_IN: u32 = 0x1; -#[cfg(test)] -pub(crate) const USBD_TRANSFER_DIRECTION_OUT: u32 = 0x0; - -#[cfg(test)] -pub(crate) const USBD_DEFAULT_PIPE_TRANSFER: u32 = 0x8; - -#[cfg(test)] -pub(crate) const USBD_START_ISO_TRANSFER_ASAP: u32 = 0x4; - /// The maximum number of endpoints EP 1-15 (IN + OUT) excluding EP 0, in a USB device. /// (see USB2.0 Spec 9.6.6 Endpoint). pub const MAX_NON_DEFAULT_EP_COUNT: usize = 30; @@ -114,18 +105,3 @@ impl Decode<'_> for UsbdIsoPacketDesc { Ok(Self { offset, length, status }) } } - -#[cfg(test)] -macro_rules! round_trip { - ($en:expr, $de:ty) => {{ - let mut buf = alloc::vec![0; $en.size()]; - $en.encode(&mut ironrdp_core::WriteCursor::new(&mut buf)).unwrap(); - let mut src = ironrdp_core::ReadCursor::new(&buf); - $crate::pdu::header::SharedMsgHeader::decode(&mut src) - .and_then(|header| <$de>::decode(&mut src, header)) - .unwrap() - }}; -} - -#[cfg(test)] -pub(crate) use round_trip; From 610cfd0742d5322dbebbe26edee6234c5e5ef34e Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 25 May 2026 08:12:14 -0500 Subject: [PATCH 238/325] test(fuzz): add pdu_round_trip oracle and target (#1291) --- crates/ironrdp-fuzzing/src/oracles/mod.rs | 121 ++++++++++++++++++ .../pdu_round_trip/seed-empty.bin | 0 .../tests/fuzz_regression.rs | 5 + fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/pdu_round_trip.rs | 7 + 5 files changed, 140 insertions(+) create mode 100644 crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/seed-empty.bin create mode 100644 fuzz/fuzz_targets/pdu_round_trip.rs diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index 11dde0a712..24e638245b 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -180,6 +180,127 @@ pub fn pdu_decode(data: &[u8]) { let _ = decode::(data); } +/// Helper for [`pdu_round_trip`]. +/// +/// Exercises `decode` → `encode_vec` → re-`decode`, silently dropping `Err` +/// results from any stage. The oracle's value is in detecting INTERNAL +/// panics from inside the encoder/decoder (e.g., `unreachable!()` reached +/// on a valid decoded state), not in asserting Err-result symmetry. Many +/// `ironrdp-pdu` types have known asymmetric `Encode` impls that return +/// `"Encoding not implemented"` for variants the decoder still accepts; +/// those are tracked separately and not in scope for this oracle. +macro_rules! pdu_round_trip_one { + ($data:expr, $ty:ty) => {{ + if let Ok(pdu) = ironrdp_core::decode::<$ty>($data) { + if let Ok(encoded) = ironrdp_core::encode_vec(&pdu) { + let _ = ironrdp_core::decode::<$ty>(&encoded); + } + } + }}; +} + +/// Round-trip oracle: for each PDU type, exercise the +/// `decode` → `encode_vec` → re-`decode` pipeline. +/// +/// The property tested is *no internal panic from inside the encoder or +/// decoder when fed a decoder-accepted input through both directions of the +/// round-trip*. Asymmetric `Err` returns (decoder accepts something the +/// encoder reports as `"Encoding not implemented"`, or vice-versa) are not +/// in scope: those are tolerated incomplete-impl cases tracked separately. +/// +/// What this catches: +/// +/// - `unreachable!()` reached during encoding of a valid decoded state (i.e. +/// the encoder's match arms are missing a variant the decoder produces). +/// - Integer overflow / index-out-of-bounds inside the encoder on +/// decoder-accepted inputs. +/// - Panics in the decoder when fed encoder-produced bytes (re-decode path). +/// +/// What this does NOT catch: +/// +/// - Encode returning `Err`. Many PDU types intentionally return errors for +/// partially-implemented variants; exercising them is the encoder +/// developer's responsibility, not this oracle's. +/// - Re-decode returning `Err`. Surfaces an asymmetry but not a memory-safety +/// bug; tracked via filed follow-up issues, not this oracle. +/// +/// Initial type coverage mirrors `pdu_decode` so the same corpus feeds both +/// oracles. As new PDU types gain `Encode` impls, they auto-extend coverage +/// here when added to the macro list below. +pub fn pdu_round_trip(data: &[u8]) { + use ironrdp_pdu::mcs::{ConnectInitial, ConnectResponse, McsMessage}; + use ironrdp_pdu::nego::{ConnectionConfirm, ConnectionRequest}; + use ironrdp_pdu::rdp::{ClientInfoPdu, server_error_info, server_license, vc}; + use ironrdp_pdu::x224::X224; + use ironrdp_pdu::{bitmap, codecs, fast_path, gcc, input, pcb, surface_commands}; + + // Connection-time PDUs + pdu_round_trip_one!(data, X224); + pdu_round_trip_one!(data, X224); + pdu_round_trip_one!(data, X224>); + pdu_round_trip_one!(data, ConnectInitial); + pdu_round_trip_one!(data, ConnectResponse); + pdu_round_trip_one!(data, ClientInfoPdu); + // `capability_sets::CapabilitySet` AND `headers::ShareControlHeader` both + // transit through `CapabilitySet`'s encoder, which reaches `unreachable!()` + // (crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs:447) on variants the + // decoder accepts but the encoder match doesn't cover. Internal-panic bugs; + // can't be silently dropped at the oracle layer. Smoke-fuzz reproducer: + // `[6, 0, 4, 0]`. To be filed as a follow-up. + pdu_round_trip_one!(data, pcb::PreconnectionBlob); + pdu_round_trip_one!(data, server_error_info::ServerSetErrorInfoPdu); + + // GCC blocks and conference creation + pdu_round_trip_one!(data, gcc::ClientGccBlocks); + pdu_round_trip_one!(data, gcc::ServerGccBlocks); + pdu_round_trip_one!(data, gcc::ClientClusterData); + pdu_round_trip_one!(data, gcc::ConferenceCreateRequest); + pdu_round_trip_one!(data, gcc::ConferenceCreateResponse); + + // Licensing + pdu_round_trip_one!(data, server_license::LicensePdu); + + // Virtual channel header + pdu_round_trip_one!(data, vc::ChannelPduHeader); + + // Fast-path framing + pdu_round_trip_one!(data, fast_path::FastPathHeader); + pdu_round_trip_one!(data, fast_path::FastPathUpdatePdu<'_>); + + // Surface commands + pdu_round_trip_one!(data, surface_commands::SurfaceCommand<'_>); + pdu_round_trip_one!(data, surface_commands::SurfaceBitsPdu<'_>); + pdu_round_trip_one!(data, surface_commands::FrameMarkerPdu); + pdu_round_trip_one!(data, surface_commands::ExtendedBitmapDataPdu<'_>); + pdu_round_trip_one!(data, surface_commands::BitmapDataHeader); + + // Codecs + pdu_round_trip_one!(data, codecs::rfx::Block<'_>); + + // Input + pdu_round_trip_one!(data, input::InputEventPdu); + pdu_round_trip_one!(data, input::InputEvent); + + // Bitmap RDP6 + pdu_round_trip_one!(data, bitmap::rdp6::BitmapStream<'_>); + + // Clipboard + pdu_round_trip_one!(data, ironrdp_cliprdr::pdu::ClipboardPdu<'_>); + pdu_round_trip_one!(data, ironrdp_cliprdr::pdu::PackedFileList); + pdu_round_trip_one!(data, ironrdp_cliprdr::pdu::FileContentsRequest); + pdu_round_trip_one!(data, ironrdp_cliprdr::pdu::FileContentsResponse<'_>); + + // RDPDR + pdu_round_trip_one!(data, ironrdp_rdpdr::pdu::RdpdrPdu); + + // Display control + pdu_round_trip_one!(data, ironrdp_displaycontrol::pdu::DisplayControlPdu); + + // RDPSND + pdu_round_trip_one!(data, ironrdp_rdpsnd::pdu::ServerAudioOutputPdu<'_>); + pdu_round_trip_one!(data, ironrdp_rdpsnd::pdu::ClientAudioOutputPdu); +} + pub fn rle_decompress_bitmap(input: BitmapInput<'_>) { let mut out = Vec::new(); diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/seed-empty.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs index 952ec82404..986dd32b66 100644 --- a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs +++ b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs @@ -46,3 +46,8 @@ fn check_bulk_decompress_xcrush() { fn check_bulk_round_trip() { check!(bulk_round_trip); } + +#[test] +fn check_pdu_round_trip() { + check!(pdu_round_trip); +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index bded424d5c..37b519bcb8 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -83,3 +83,10 @@ test = false doc = false bench = false +[[bin]] +name = "pdu_round_trip" +path = "fuzz_targets/pdu_round_trip.rs" +test = false +doc = false +bench = false + diff --git a/fuzz/fuzz_targets/pdu_round_trip.rs b/fuzz/fuzz_targets/pdu_round_trip.rs new file mode 100644 index 0000000000..9e9e545323 --- /dev/null +++ b/fuzz/fuzz_targets/pdu_round_trip.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::pdu_round_trip(data); +}); From 6d43d2692d206b7557f722f294d3e51d7eac8ab1 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 25 May 2026 08:20:40 -0500 Subject: [PATCH 239/325] feat(graphics,egfx): add progressive RFX server encode and mixed-codec frames (#1198) --- crates/ironrdp-egfx/src/server.rs | 118 ++++++ crates/ironrdp-graphics/src/progressive.rs | 422 ++++++++++++++++++++- 2 files changed, 539 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-egfx/src/server.rs b/crates/ironrdp-egfx/src/server.rs index 1d453ecf90..fed495a4a0 100644 --- a/crates/ironrdp-egfx/src/server.rs +++ b/crates/ironrdp-egfx/src/server.rs @@ -869,6 +869,39 @@ pub struct GraphicsPipelineServer { compression_mode: CompressionMode, } +/// Payload for a single tile within a mixed-codec frame. +/// +/// Each variant corresponds to a different EGFX codec. Used with +/// [`GraphicsPipelineServer::send_mixed_frame()`] to pack multiple codec +/// types into a single `StartFrame`/`EndFrame` pair. +/// +/// Marked `#[non_exhaustive]` so future EGFX codec additions (for example, +/// Avc444 or hardware-accelerated paths) can land without a SemVer break +/// for downstream consumers that pattern-match on this enum. +#[non_exhaustive] +pub enum MixedTilePayload { + /// Lossless ClearCodec tile (text, UI elements, icons). + /// `bitmap_data` is a pre-encoded ClearCodec bitmap stream. + /// `destination` uses `ExclusiveRectangle` to match the spec-defined + /// `WireToSurface1Pdu.destination_rectangle` field type (MS-RDPEGFX + /// 2.2.1.4.1: right/bottom are exclusive). + ClearCodec { + destination: ExclusiveRectangle, + bitmap_data: Vec, + }, + /// RemoteFX Progressive tile (photos, gradients). + /// `progressive_data` is a valid progressive block stream. + RemoteFxProgressive { + codec_context_id: u32, + progressive_data: Vec, + }, + /// H.264 AVC420 tile (video, high-motion content). + Avc420 { + regions: Vec, + h264_data: Vec, + }, +} + impl GraphicsPipelineServer { /// Create a new GraphicsPipelineServer pub fn new(handler: Box) -> Self { @@ -1464,6 +1497,91 @@ impl GraphicsPipelineServer { Some(frame_id) } + // ======================================================================== + // Mixed-Codec Frame Support + // ======================================================================== + + /// Queue a mixed-codec frame containing tiles encoded with different codecs. + /// + /// This is the core of multi-codec EGFX: a single frame update can contain + /// ClearCodec tiles (lossless text), Progressive tiles (photos), and H.264 + /// tiles (video), all sent between one `StartFrame`/`EndFrame` pair. + /// + /// This matches how Azure VDI achieves its visual quality — each tile uses + /// the codec best suited to its content type. + /// + /// Returns `Some(frame_id)` if queued, `None` if not ready or backpressured. + pub fn send_mixed_frame( + &mut self, + surface_id: u16, + tiles: Vec, + timestamp_ms: u32, + ) -> Option { + if !self.is_ready() { + return None; + } + if self.should_backpressure() { + return None; + } + if tiles.is_empty() { + return None; + } + + let surface = self.surfaces.get(surface_id)?; + let pixel_format = surface.pixel_format; + + let timestamp = Self::make_timestamp(timestamp_ms); + let frame_id = self.frames.begin_frame(timestamp); + + self.output_queue + .push_back(GfxPdu::StartFrame(StartFramePdu { timestamp, frame_id })); + + for tile in tiles { + match tile { + MixedTilePayload::ClearCodec { + destination, + bitmap_data, + } => { + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::ClearCodec, + pixel_format, + destination_rectangle: destination, + bitmap_data, + })); + } + MixedTilePayload::RemoteFxProgressive { + codec_context_id, + progressive_data, + } => { + self.output_queue.push_back(GfxPdu::WireToSurface2(WireToSurface2Pdu { + surface_id, + codec_id: Codec2Type::RemoteFxProgressive, + codec_context_id, + pixel_format, + bitmap_data: progressive_data, + })); + } + MixedTilePayload::Avc420 { regions, h264_data } => { + let encoded_stream = encode_avc420_bitmap_stream(®ions, &h264_data); + let target_rect = Self::compute_dest_rect(®ions, surface.width, surface.height); + + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::Avc420, + pixel_format, + destination_rectangle: target_rect, + bitmap_data: encoded_stream, + })); + } + } + } + + self.output_queue.push_back(GfxPdu::EndFrame(EndFramePdu { frame_id })); + + Some(frame_id) + } + // ======================================================================== // Output Management // ======================================================================== diff --git a/crates/ironrdp-graphics/src/progressive.rs b/crates/ironrdp-graphics/src/progressive.rs index 2c31a02f31..315b8aa96f 100644 --- a/crates/ironrdp-graphics/src/progressive.rs +++ b/crates/ironrdp-graphics/src/progressive.rs @@ -254,6 +254,207 @@ pub fn progressive_quantize(coefficients: &mut [i16], prog_quant: &ComponentCode } } +// --------------------------------------------------------------------------- +// Server-side encode pipeline +// --------------------------------------------------------------------------- + +/// Encode a first-pass component from spatial-domain coefficients. +/// +/// Pipeline: forward DWT -> base quantization -> progressive quantization +/// -> LL3 delta encode -> RLGR1 encode. +/// +/// Returns the number of bytes written to `output`. +/// +/// # Arguments +/// - `coefficients`: spatial-domain coefficients (4096 i16, modified in-place) +/// - `output`: output buffer for RLGR1-encoded data +/// - `base_quant`: base quantization values +/// - `prog_quant`: progressive quantization BitPos values for this quality level +/// - `use_reduce_extrapolate`: DWT mode flag +/// +/// # Panics +/// +/// Panics if `coefficients` has fewer than 4096 elements. +/// +/// # Errors +/// Returns `RlgrError` if RLGR encoding fails. +pub fn encode_first_pass( + coefficients: &mut [i16], + output: &mut [u8], + base_quant: &ComponentCodecQuant, + prog_quant: &ComponentCodecQuant, + use_reduce_extrapolate: bool, +) -> Result { + assert!(coefficients.len() >= COEFFICIENTS_PER_COMPONENT); + + let mut temp = [0i16; COEFFICIENTS_PER_COMPONENT]; + + // Step 1: Forward DWT + if use_reduce_extrapolate { + crate::dwt_extrapolate::encode(coefficients, &mut temp); + } else { + crate::dwt::encode(coefficients, &mut temp); + } + + // Step 2: Base quantization (right-shift by quant - 1) + quantize_component_ccq(coefficients, base_quant, use_reduce_extrapolate); + + // Step 3: Progressive quantization (right-shift by BitPos) + progressive_quantize(coefficients, prog_quant, use_reduce_extrapolate); + + // Step 4: LL3 delta encoding + crate::subband_reconstruction::encode(&mut coefficients[ll3_offset(use_reduce_extrapolate)..]); + + // Step 5: RLGR1 entropy encode + crate::rlgr::encode(EntropyAlgorithm::Rlgr1, coefficients, output) +} + +/// Base quantization using `ComponentCodecQuant` (progressive format). +/// +/// Each band is right-shifted by `(quant_value - 1)`. Inverse of `dequantize_component_ccq`. +fn quantize_component_ccq(coefficients: &mut [i16], quant: &ComponentCodecQuant, use_reduce_extrapolate: bool) { + let bands = get_band_layout(use_reduce_extrapolate); + + for (band_idx, band) in bands.iter().enumerate() { + let q = quant.for_band(band_idx); + let factor = q.saturating_sub(1); + if factor > 0 { + let start = band.offset; + let end = start + band.count(); + for coeff in &mut coefficients[start..end] { + // Truncation toward zero (same as classic quantization::encode) + let val = i32::from(*coeff); + if val >= 0 { + *coeff = clamp_i16(val >> i32::from(factor)); + } else { + *coeff = clamp_i16(-((-val) >> i32::from(factor))); + } + } + } + } +} + +/// Compute the upgrade-pass data for a single component. +/// +/// Given the previous and current progressive quantization, produces +/// SRL-encoded data (for zero-DAS positions) and raw bit data (for +/// non-zero DAS positions) representing the refinement. +/// +/// # Arguments +/// - `coefficients`: current full-resolution DWT coefficients for this component +/// - `prev_coefficients`: coefficients as reconstructed from the previous pass +/// - `prev_prog_quant`: BitPos values from the previous pass +/// - `curr_prog_quant`: BitPos values for this upgrade pass +/// - `sign`: DAS sign array from the previous pass +/// - `use_reduce_extrapolate`: DWT mode flag +/// +/// # Returns +/// A tuple of `(srl_data, raw_data)` byte vectors. +/// +/// # Wire-format invariants (MS-RDPRFX 3.1.8.1.7.2) +/// +/// The non-zero-DAS raw-magnitude path uses `saturating_sub` to compute +/// `raw_mag = curr_q - prev_q`. Upgrade passes are *monotonic refinements*: +/// the encoder only adds magnitude bits, never subtracts. The decoder's +/// counterpart accumulates raw_mag onto the previously-decoded coefficient +/// with the DAS-determined sign (`+=` for SIGN_POSITIVE / LL3, `-=` for +/// SIGN_NEGATIVE), so a hypothetical signed delta would have no place in +/// the wire format. Switching this to a signed-delta encoding would break +/// wire compatibility with mstsc/FreeRDP — do not "fix" the saturating_sub. +/// +/// The zero-DAS SRL path uses `clamp_i16(curr_shifted - prev_shifted)`. SRL +/// stream values are i16 by wire-format definition, so wider precision is +/// not available without a spec extension. The clamp is the wire-format +/// boundary, not a precision compromise. +pub fn encode_upgrade_pass( + coefficients: &[i16], + prev_coefficients: &[i16], + prev_prog_quant: &ComponentCodecQuant, + curr_prog_quant: &ComponentCodecQuant, + sign: &[i8], + use_reduce_extrapolate: bool, +) -> (Vec, Vec) { + let bands = get_band_layout(use_reduce_extrapolate); + let mut all_srl_values = Vec::new(); + let mut raw_writer = RawBitWriter::new(); + + for (band_idx, band) in bands.iter().enumerate() { + let prev_bit_pos = prev_prog_quant.for_band(band_idx); + let curr_bit_pos = curr_prog_quant.for_band(band_idx); + + let num_bits = prev_bit_pos.saturating_sub(curr_bit_pos); + if num_bits == 0 { + continue; + } + + let mut band_srl_values = Vec::new(); + + for i in 0..band.count() { + let coeff_idx = band.offset + i; + + if sign[coeff_idx] == SIGN_ZERO { + // Zero-DAS: compute the refined value and encode via SRL + let curr_shifted = i32::from(coefficients[coeff_idx]) >> i32::from(curr_bit_pos); + let prev_shifted = i32::from(prev_coefficients[coeff_idx]) >> i32::from(curr_bit_pos); + let delta = clamp_i16(curr_shifted - prev_shifted); + band_srl_values.push(delta); + } else { + // Non-zero DAS: compute raw magnitude bits + let curr_abs = i32::from(coefficients[coeff_idx]).unsigned_abs(); + let prev_abs = i32::from(prev_coefficients[coeff_idx]).unsigned_abs(); + + let curr_q = curr_abs >> u32::from(curr_bit_pos); + let prev_q = prev_abs >> u32::from(curr_bit_pos); + let raw_mag = curr_q.saturating_sub(prev_q); + + raw_writer.write_bits(raw_mag, u32::from(num_bits)); + } + } + + // Encode SRL values for this band + let srl_encoded = srl::encode_srl(&band_srl_values, num_bits); + all_srl_values.extend_from_slice(&srl_encoded); + } + + let raw_data = raw_writer.finish(); + (all_srl_values, raw_data) +} + +/// Encode RGBA pixels to spatial-domain i16 coefficients (RGB to YCbCr). +/// +/// Performs ITU-R BT.601 RGB-to-YCbCr conversion on a 64x64 pixel tile. +/// Output is 3 buffers of 4096 i16 coefficients (Y, Cb, Cr) in tile order. +/// +/// # Panics +/// +/// Panics if `pixels` has fewer than 64 * 64 * 4 = 16384 bytes. +#[expect(clippy::similar_names)] +pub fn rgba_to_ycbcr(pixels: &[u8], y_out: &mut [i16], cb_out: &mut [i16], cr_out: &mut [i16]) { + assert!(pixels.len() >= 64 * 64 * 4); + assert!(y_out.len() >= COEFFICIENTS_PER_COMPONENT); + assert!(cb_out.len() >= COEFFICIENTS_PER_COMPONENT); + assert!(cr_out.len() >= COEFFICIENTS_PER_COMPONENT); + + for i in 0..64 * 64 { + let off = i * 4; + let r = i32::from(pixels[off]); + let g = i32::from(pixels[off + 1]); + let b = i32::from(pixels[off + 2]); + + // ITU-R BT.601: Y = 0.299R + 0.587G + 0.114B + // Cb = -0.169R - 0.331G + 0.500B + // Cr = 0.500R - 0.419G - 0.081B + // Fixed-point with 16-bit precision + let y = ((19595 * r + 38470 * g + 7471 * b + 32768) >> 16) - 128; + let cb = (-11059 * r - 21709 * g + 32768 * b + 32768) >> 16; + let cr = (32768 * r - 27439 * g - 5329 * b + 32768) >> 16; + + y_out[i] = clamp_i16(y); + cb_out[i] = clamp_i16(cb); + cr_out[i] = clamp_i16(cr); + } +} + /// Base dequantization using `ComponentCodecQuant` (progressive-format quantization). /// /// Each band is shifted left by `(quant_value - 1)`. Uses `for_band()` to map @@ -367,9 +568,59 @@ fn clamp_i16(value: i32) -> i16 { } // --------------------------------------------------------------------------- -// Raw bit reader for upgrade pass +// Raw bit I/O for upgrade pass // --------------------------------------------------------------------------- +/// Writes raw magnitude bits MSB-first to a byte stream. +/// +/// Symmetric counterpart of [`RawBitReader`]. Callers are expected to pass +/// `count <= 32` to [`write_bits`](Self::write_bits); the upgrade-pass call +/// site bounds `count` by `prev_bit_pos - curr_bit_pos` which is at most a +/// few bits in practice. `count > 32` reads beyond `u32` width in the shift +/// expression, which is wrap-on-release / panic-on-debug — caller responsibility. +struct RawBitWriter { + bytes: Vec, + current: u8, + bit_count: u8, +} + +impl RawBitWriter { + fn new() -> Self { + Self { + bytes: Vec::new(), + current: 0, + bit_count: 0, + } + } + + fn write_bit(&mut self, bit: bool) { + self.current = (self.current << 1) | u8::from(bit); + self.bit_count += 1; + if self.bit_count >= 8 { + self.bytes.push(self.current); + self.current = 0; + self.bit_count = 0; + } + } + + /// Write the low `count` bits of `value`, MSB-first. Caller must ensure + /// `count <= 32` (see type-level docs). + fn write_bits(&mut self, value: u32, count: u32) { + debug_assert!(count <= 32, "RawBitWriter::write_bits count must be <= 32"); + for i in (0..count).rev() { + self.write_bit((value >> i) & 1 != 0); + } + } + + fn finish(mut self) -> Vec { + if self.bit_count > 0 { + self.current <<= 8 - self.bit_count; + self.bytes.push(self.current); + } + self.bytes + } +} + /// Reads raw magnitude bits MSB-first from a byte stream. /// /// Past-end-of-stream reads return zero bits rather than an error: a @@ -1425,4 +1676,173 @@ mod tests { // LL3: shift left by (3 - 1) = 2 -> 5 << 2 = 20 assert_eq!(coefficients[4032], 20); } + + // --- B10: Server encode pipeline tests --- + + #[test] + fn rgba_to_ycbcr_pure_white() { + let pixels = vec![255u8; 64 * 64 * 4]; + let mut y = vec![0i16; 4096]; + let mut cb = vec![0i16; 4096]; + let mut cr = vec![0i16; 4096]; + + rgba_to_ycbcr(&pixels, &mut y, &mut cb, &mut cr); + + // Pure white: R=G=B=255 + // Y = (19595*255 + 38470*255 + 7471*255 + 32768) >> 16 - 128 + // = (65536*255 + 32768) >> 16 - 128 = 255 - 128 = 127 + // Cb and Cr should be ~0 (achromatic) + assert!((y[0] - 127).abs() <= 1, "Y for white: got {}", y[0]); + assert!(cb[0].abs() <= 1, "Cb for white: got {}", cb[0]); + assert!(cr[0].abs() <= 1, "Cr for white: got {}", cr[0]); + } + + #[test] + fn rgba_to_ycbcr_pure_black() { + let pixels = vec![0u8; 64 * 64 * 4]; + let mut y = vec![0i16; 4096]; + let mut cb = vec![0i16; 4096]; + let mut cr = vec![0i16; 4096]; + + rgba_to_ycbcr(&pixels, &mut y, &mut cb, &mut cr); + + // Pure black: Y = -128, Cb = 0, Cr = 0 + assert_eq!(y[0], -128); + assert_eq!(cb[0], 0); + assert_eq!(cr[0], 0); + } + + #[test] + fn quantize_ccq_right_shifts() { + let mut coefficients = [0i16; 4096]; + coefficients[0] = 80; // HL1 band + coefficients[4032] = 20; // LL3 band + + let quant = ComponentCodecQuant { + ll3: 3, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 4, + lh1: 0, + hh1: 0, + }; + + quantize_component_ccq(&mut coefficients, &quant, false); + + // HL1: 80 >> (4 - 1) = 80 >> 3 = 10 + assert_eq!(coefficients[0], 10); + // LL3: 20 >> (3 - 1) = 20 >> 2 = 5 + assert_eq!(coefficients[4032], 5); + } + + #[test] + fn quantize_ccq_negative_truncates_toward_zero() { + let mut coefficients = [0i16; 4096]; + coefficients[0] = -80; // HL1 band, negative + + let quant = ComponentCodecQuant { + ll3: 0, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 4, + lh1: 0, + hh1: 0, + }; + + quantize_component_ccq(&mut coefficients, &quant, false); + + // -80 truncated toward zero: -(80 >> 3) = -10 + assert_eq!(coefficients[0], -10); + } + + #[test] + fn raw_bit_writer_single_byte() { + let mut w = RawBitWriter::new(); + w.write_bits(0xA5, 8); + assert_eq!(w.finish(), vec![0xA5]); + } + + #[test] + fn raw_bit_writer_partial_byte_padded() { + let mut w = RawBitWriter::new(); + w.write_bits(0b101, 3); + // 3 bits: 101, padded to 10100000 = 0xA0 + assert_eq!(w.finish(), vec![0xA0]); + } + + #[test] + fn raw_bit_writer_multi_byte() { + let mut w = RawBitWriter::new(); + w.write_bits(0xFF, 8); + w.write_bits(0b1010, 4); + // First byte: 0xFF, second partial: 1010_0000 = 0xA0 + assert_eq!(w.finish(), vec![0xFF, 0xA0]); + } + + #[test] + fn encode_first_pass_produces_output() { + // Flat tile: all same value, should compress well + let mut coefficients = [100i16; 4096]; + let mut output = vec![0u8; 8192]; + + let base_quant = ComponentCodecQuant::LOSSLESS; + let prog_quant = ComponentCodecQuant::LOSSLESS; + + let result = encode_first_pass(&mut coefficients, &mut output, &base_quant, &prog_quant, false); + + assert!(result.is_ok(), "RLGR encode failed: {:?}", result.err()); + let bytes_written = result.unwrap(); + assert!(bytes_written > 0, "expected non-zero encoded output"); + assert!(bytes_written < 8192, "flat tile should compress"); + } + + #[test] + fn encode_first_pass_reduce_extrapolate() { + let mut coefficients = [50i16; 4096]; + let mut output = vec![0u8; 8192]; + + let base_quant = ComponentCodecQuant::LOSSLESS; + let prog_quant = ComponentCodecQuant::LOSSLESS; + + let result = encode_first_pass( + &mut coefficients, + &mut output, + &base_quant, + &prog_quant, + true, // reduce-extrapolate mode + ); + + assert!(result.is_ok(), "RLGR encode failed: {:?}", result.err()); + assert!(result.unwrap() > 0); + } + + #[test] + fn encode_upgrade_pass_empty_when_no_refinement() { + let coefficients = [0i16; 4096]; + let prev_coefficients = [0i16; 4096]; + let sign = [SIGN_ZERO; 4096]; + + // Same prog_quant for prev and curr -> num_bits = 0, no refinement + let prog_quant = ComponentCodecQuant::LOSSLESS; + + let (srl_data, raw_data) = encode_upgrade_pass( + &coefficients, + &prev_coefficients, + &prog_quant, + &prog_quant, + &sign, + false, + ); + + assert!(srl_data.is_empty(), "no refinement bits, SRL should be empty"); + assert!(raw_data.is_empty(), "no refinement bits, raw should be empty"); + } } From 905a148604e7bac67cdcb2e915e3cacd29693f57 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 25 May 2026 08:35:40 -0500 Subject: [PATCH 240/325] fix(rdpsnd-native): allocate Opus PCM buffer as Vec to avoid alignment panic (#1256) --- crates/ironrdp-rdpsnd-native/src/cpal.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/ironrdp-rdpsnd-native/src/cpal.rs b/crates/ironrdp-rdpsnd-native/src/cpal.rs index ef338105d2..e47157a20f 100644 --- a/crates/ironrdp-rdpsnd-native/src/cpal.rs +++ b/crates/ironrdp-rdpsnd-native/src/cpal.rs @@ -164,11 +164,19 @@ impl DecodeStream { clippy::as_conversions, reason = "opus::Channels has no conversions to usize implemented" )] - let mut pcm = vec![0u8; nb_samples * chan as usize * size_of::()]; - if let Err(error) = dec.decode(&pkt, bytemuck::cast_slice_mut(pcm.as_mut_slice()), false) { + let mut pcm_i16 = vec![0i16; nb_samples * chan as usize]; + if let Err(error) = dec.decode(&pkt, &mut pcm_i16, false) { error!(?error, "Failed to decode an Opus packet"); continue; } + // Vec is what the channel carries downstream. Reinterpreting + // Vec -> Vec via cast_slice is safe (smaller alignment). + // Allocating as Vec in the first place avoids the alignment + // hazard of `bytemuck::cast_slice_mut::` panicking when + // the allocator hands back a u8 buffer that is not 2-byte aligned + // (which manifested as a hard crash in #1202 under the burst of + // malformed Opus packets generated by a server reboot). + let pcm = bytemuck::cast_slice(&pcm_i16).to_vec(); if dec_tx.send(pcm).is_err() { error!("Failed to send the decoded Opus packet over the channel"); From 7e0bfd3c550135a3c9c85cb66a478ce41c8641d9 Mon Sep 17 00:00:00 2001 From: clintcan Date: Mon, 25 May 2026 21:38:01 +0800 Subject: [PATCH 241/325] feat(cliprdr): always set FD_PROGRESSUI in FileDescriptor::encode (#1299) --- crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs b/crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs index 7538ea9a6b..132ce6e2e8 100644 --- a/crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs +++ b/crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs @@ -151,7 +151,14 @@ impl Encode for FileDescriptor { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - let mut flags = ClipboardFileFlags::empty(); + // Always advertise FD_PROGRESSUI (SHOW_PROGRESS_UI = 0x4000) so the + // remote knows it MAY show a progress indicator for this file. The + // flag is benign if the remote doesn't honor it; for clipboard file + // paste into Windows Explorer it is the actual trigger that makes + // the native "Copying… items" progress dialog appear (otherwise the + // paste falls back to a synchronous IStream read with no progress + // UI, only a busy cursor — same UX as pasting an Outlook attachment). + let mut flags = ClipboardFileFlags::SHOW_PROGRESS_UI; if self.attributes.is_some() { flags |= ClipboardFileFlags::ATTRIBUTES; } From 5375bbb9ddb8b853973d050fa2efd0ed217ac17b Mon Sep 17 00:00:00 2001 From: clintcan Date: Mon, 25 May 2026 21:50:32 +0800 Subject: [PATCH 242/325] feat(cliprdr): advertise Preferred DropEffect alongside FileGroupDescriptorW (#1301) `initiate_file_copy` now advertises **both** `FileGroupDescriptorW` and `Preferred DropEffect` (`CFSTR_PREFERREDDROPEFFECT`) in the FormatList, and `handle_format_data_request` short-circuits a request for the latter with `DROPEFFECT_COPY` (0x00000001 LE). --- crates/ironrdp-cliprdr/src/lib.rs | 44 +++++++++++++++++-- crates/ironrdp-cliprdr/src/pdu/format_list.rs | 8 ++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/ironrdp-cliprdr/src/lib.rs b/crates/ironrdp-cliprdr/src/lib.rs index 4fe2fe1b34..62a22638c8 100644 --- a/crates/ironrdp-cliprdr/src/lib.rs +++ b/crates/ironrdp-cliprdr/src/lib.rs @@ -387,6 +387,13 @@ pub struct Cliprdr { /// Tracked so we can recognize FormatDataRequest for our file list. local_file_list_format_id: Option, + /// Format ID used for the local "Preferred DropEffect" entry in the + /// FormatList sent alongside FileGroupDescriptorW from + /// [`Cliprdr::initiate_file_copy`]. Tracked so we can recognize a + /// FormatDataRequest for it and respond inline with `DROPEFFECT_COPY` + /// (0x00000001) — backends don't have to know about the format. + local_drop_effect_format_id: Option, + /// Stores the remote file list after receiving it via FormatDataResponse. /// Used for validating FileContentsRequest.lindex bounds. remote_file_list: Option, @@ -528,6 +535,7 @@ impl Cliprdr { pending_format_data_request: None, local_file_list: None, local_file_list_format_id: None, + local_drop_effect_format_id: None, remote_file_list: None, remote_file_list_format_id: None, sent_file_contents_requests: HashMap::new(), @@ -628,6 +636,7 @@ impl Cliprdr { self.local_file_list = None; self.local_file_list_format_id = None; + self.local_drop_effect_format_id = None; if !self.sent_file_contents_requests.is_empty() { info!( @@ -808,6 +817,7 @@ impl Cliprdr { // in-progress file download - acceptable since the user explicitly chose new content. self.local_file_list = None; self.local_file_list_format_id = None; + self.local_drop_effect_format_id = None; let mut pdus = Vec::new(); @@ -1500,11 +1510,27 @@ impl Cliprdr { // FormatDataRequest, they will use our ID (0xC0FE), which we use to recognize the request // in handle_format_data_request. const FILE_LIST_FORMAT_ID: u32 = 0xC0FE; + // Distinct private-range ID for the companion "Preferred DropEffect" + // entry. The value doesn't matter on the wire (the remote keys off + // the format *name*); it just has to be locally unique so we can + // tell which FormatDataRequest is which. + const DROP_EFFECT_FORMAT_ID: u32 = 0xC0FD; let format_id = ClipboardFormatId::new(FILE_LIST_FORMAT_ID); - let formats = vec![ClipboardFormat::new(format_id).with_name(ClipboardFormatName::FILE_LIST)]; - - // Track the format ID we're using for this file list + let drop_effect_id = ClipboardFormatId::new(DROP_EFFECT_FORMAT_ID); + // Advertise both FileGroupDescriptorW AND Preferred DropEffect. + // Windows Explorer pairs these locally and uses the latter to engage + // its shell file-copy machinery (with the native progress dialog) + // on paste — without it, Explorer falls back to a plain synchronous + // IStream read with no progress UI. + let formats = vec![ + ClipboardFormat::new(format_id).with_name(ClipboardFormatName::FILE_LIST), + ClipboardFormat::new(drop_effect_id).with_name(ClipboardFormatName::PREFERRED_DROP_EFFECT), + ]; + + // Track the format IDs we're using for the file list and drop effect + // so handle_format_data_request can recognize and answer them inline. self.local_file_list_format_id = Some(format_id); + self.local_drop_effect_format_id = Some(drop_effect_id); let format_list = self.build_format_list(&formats).map_err(|e| encode_err!(e))?; let pdu = ClipboardPdu::FormatList(format_list); @@ -1588,6 +1614,18 @@ impl SvcProcessor for Cliprdr { Ok(Vec::new()) } ClipboardPdu::FormatDataRequest(request) => { + // Short-circuit: if the remote is asking for our Preferred + // DropEffect, answer inline with DROPEFFECT_COPY (0x00000001, + // 4-byte little-endian). This is what we always mean by an + // outbound file copy (`initiate_file_copy` is named for + // exactly this), so answering inline keeps backends from + // having to know about the format. + if Some(request.format) == self.local_drop_effect_format_id { + const DROPEFFECT_COPY: u32 = 0x0000_0001; + let response = OwnedFormatDataResponse::new_data(DROPEFFECT_COPY.to_le_bytes().to_vec()); + let pdu = ClipboardPdu::FormatDataResponse(response); + return Ok(vec![into_cliprdr_message(pdu)]); + } // Check if this is a request for our stored file list by comparing format IDs if Some(request.format) == self.local_file_list_format_id { if let Some(ref file_list) = self.local_file_list { diff --git a/crates/ironrdp-cliprdr/src/pdu/format_list.rs b/crates/ironrdp-cliprdr/src/pdu/format_list.rs index be473dd498..02fd462931 100644 --- a/crates/ironrdp-cliprdr/src/pdu/format_list.rs +++ b/crates/ironrdp-cliprdr/src/pdu/format_list.rs @@ -153,6 +153,14 @@ impl ClipboardFormatName { /// Special format defined by Windows to store HTML fragment in clipboard. pub const HTML: Self = Self::new_static("HTML Format"); + /// `CFSTR_PREFERREDDROPEFFECT`: 4-byte little-endian `DROPEFFECT` value + /// (1 = DROPEFFECT_COPY, 2 = DROPEFFECT_MOVE). Conventionally placed on + /// the clipboard alongside [`Self::FILE_LIST`] to label the operation + /// as a copy. When present, Windows Explorer engages its shell + /// file-copy machinery on paste (with the native "Copying… items" + /// progress dialog) instead of doing a plain synchronous IStream read. + pub const PREFERRED_DROP_EFFECT: Self = Self::new_static("Preferred DropEffect"); + pub fn new(name: impl Into>) -> Self { Self(name.into()) } From 424590ac76f3f82de19b3d6d1aa7a0119f616fab Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 25 May 2026 08:51:49 -0500 Subject: [PATCH 243/325] fix(server): drop raw user_data dump from McsMessage::SendDataRequest debug log (#1295) --- crates/ironrdp-server/src/server.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 08b5fb2b91..6dee90d233 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1178,7 +1178,12 @@ impl RdpServer { let message = decode::>>(frame)?; match message.0 { mcs::McsMessage::SendDataRequest(data) => { - debug!(?data, "McsMessage::SendDataRequest"); + debug!( + initiator_id = data.initiator_id, + channel_id = data.channel_id, + user_data_len = data.user_data.len(), + "McsMessage::SendDataRequest" + ); if data.channel_id == io_channel_id { return self.handle_io_channel_data(data).await; } From 3dae1fbacca695cee17e9a4e08caf22dd711faac Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 25 May 2026 08:54:24 -0500 Subject: [PATCH 244/325] test(fuzz): add egfx PDU decoders to pdu_decode oracle + fix two surfaced bugs (#1271) --- Cargo.lock | 1 + crates/ironrdp-egfx/src/pdu/avc.rs | 19 ++++++++++++++++--- crates/ironrdp-fuzzing/Cargo.toml | 1 + crates/ironrdp-fuzzing/src/oracles/mod.rs | 13 +++++++++++++ fuzz/Cargo.lock | 14 ++++++++++++++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c26f8d308..4557fc3532 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2644,6 +2644,7 @@ dependencies = [ "ironrdp-cliprdr-format", "ironrdp-core", "ironrdp-displaycontrol", + "ironrdp-egfx", "ironrdp-graphics", "ironrdp-pdu", "ironrdp-rdpdr", diff --git a/crates/ironrdp-egfx/src/pdu/avc.rs b/crates/ironrdp-egfx/src/pdu/avc.rs index e310eb5eca..625a409865 100644 --- a/crates/ironrdp-egfx/src/pdu/avc.rs +++ b/crates/ironrdp-egfx/src/pdu/avc.rs @@ -117,8 +117,15 @@ impl<'de> Decode<'de> for Avc420BitmapStream<'de> { let num_regions = src.read_u32(); #[expect(clippy::as_conversions, reason = "num_regions bounded by practical limits")] let num_regions_usize = num_regions as usize; - let mut rectangles = Vec::with_capacity(num_regions_usize); - let mut quant_qual_vals = Vec::with_capacity(num_regions_usize); + // Cap pre-allocation against the remaining buffer to avoid OOM from a + // malicious num_regions: each region needs at least one rectangle + // (8 bytes) plus one QuantQuality entry (2 bytes). The actual read + // loop will fail with NotEnoughBytes if num_regions is bogus. + let per_region = InclusiveRectangle::FIXED_PART_SIZE + QuantQuality::FIXED_PART_SIZE; + let max_possible = src.len() / per_region; + let bounded_capacity = num_regions_usize.min(max_possible); + let mut rectangles = Vec::with_capacity(bounded_capacity); + let mut quant_qual_vals = Vec::with_capacity(bounded_capacity); for _ in 0..num_regions { rectangles.push(InclusiveRectangle::decode(src)?); } @@ -215,7 +222,13 @@ impl<'de> Decode<'de> for Avc444BitmapStream<'de> { }) } else { #[expect(clippy::as_conversions, reason = "30-bit value fits in usize")] - let (mut stream1, mut stream2) = src.split_at(stream_len as usize); + let stream_len = stream_len as usize; + // Validate that the declared stream length fits in the remaining + // buffer; src.split_at panics on overflow, so a malformed + // streamLen field would otherwise crash the decoder. Surfaced + // by the pdu_decode fuzz target. + ensure_size!(ctx: Self::NAME, in: src, size: stream_len); + let (mut stream1, mut stream2) = src.split_at(stream_len); let stream1 = Avc420BitmapStream::decode(&mut stream1)?; let stream2 = if encoding == Encoding::LUMA_AND_CHROMA { Some(Avc420BitmapStream::decode(&mut stream2)?) diff --git a/crates/ironrdp-fuzzing/Cargo.toml b/crates/ironrdp-fuzzing/Cargo.toml index 0d4f6ce373..4372335170 100644 --- a/crates/ironrdp-fuzzing/Cargo.toml +++ b/crates/ironrdp-fuzzing/Cargo.toml @@ -20,6 +20,7 @@ ironrdp-rdpdr.path = "../ironrdp-rdpdr" ironrdp-rdpsnd.path = "../ironrdp-rdpsnd" ironrdp-cliprdr-format.path = "../ironrdp-cliprdr-format" ironrdp-displaycontrol.path = "../ironrdp-displaycontrol" +ironrdp-egfx.path = "../ironrdp-egfx" ironrdp-svc.path = "../ironrdp-svc" [lints] diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index 24e638245b..74cdf424e6 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -112,6 +112,10 @@ pub fn bulk_round_trip(data: &[u8]) { pub fn pdu_decode(data: &[u8]) { use ironrdp_core::decode; + use ironrdp_egfx::pdu::{ + Avc420BitmapStream, Avc444BitmapStream, CacheToSurfacePdu, CapabilitySet as EgfxCapabilitySet, Color, GfxPdu, + Point, QuantQuality, + }; use ironrdp_pdu::mcs::{ConnectInitial, ConnectResponse, McsMessage}; use ironrdp_pdu::nego::{ConnectionConfirm, ConnectionRequest}; use ironrdp_pdu::rdp::{ClientInfoPdu, capability_sets, headers, server_error_info, server_license, vc}; @@ -178,6 +182,15 @@ pub fn pdu_decode(data: &[u8]) { let _ = decode::>(data); let _ = decode::(data); + + let _ = decode::(data); + let _ = decode::(data); + let _ = decode::(data); + let _ = decode::>(data); + let _ = decode::>(data); + let _ = decode::(data); + let _ = decode::(data); + let _ = decode::(data); } /// Helper for [`pdu_round_trip`]. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index ccb95c906c..74a8a2935a 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -337,6 +337,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "ironrdp-egfx" +version = "0.1.0" +dependencies = [ + "bit_field", + "bitflags", + "ironrdp-core", + "ironrdp-dvc", + "ironrdp-graphics", + "ironrdp-pdu", + "tracing", +] + [[package]] name = "ironrdp-error" version = "0.1.3" @@ -359,6 +372,7 @@ dependencies = [ "ironrdp-cliprdr-format", "ironrdp-core", "ironrdp-displaycontrol", + "ironrdp-egfx", "ironrdp-graphics", "ironrdp-pdu", "ironrdp-rdpdr", From a4bc4753607d87ef0989d9df16a31cd22e7c7fde Mon Sep 17 00:00:00 2001 From: clintcan Date: Mon, 25 May 2026 21:59:18 +0800 Subject: [PATCH 245/325] feat(cliprdr): add CliprdrBackend::on_format_list_response(ok) hook (#1300) --- crates/ironrdp-cliprdr/src/backend.rs | 17 +++++++++++++++++ crates/ironrdp-cliprdr/src/lib.rs | 2 ++ 2 files changed, 19 insertions(+) diff --git a/crates/ironrdp-cliprdr/src/backend.rs b/crates/ironrdp-cliprdr/src/backend.rs index 26cab840f1..88f4b0eecf 100644 --- a/crates/ironrdp-cliprdr/src/backend.rs +++ b/crates/ironrdp-cliprdr/src/backend.rs @@ -83,6 +83,23 @@ pub trait CliprdrBackend: AsAny + core::fmt::Debug + Send { /// client's clipboard prior to `CLIPRDR` SVC initialization. fn on_request_format_list(&mut self); + /// Called by [`crate::Cliprdr`] when the remote responds to a `FormatList` we + /// sent (i.e. an outbound advertise of our own clipboard contents). + /// + /// `ok = true` means the remote accepted the list (`CB_RESPONSE_OK`); + /// `ok = false` means it rejected it (`CB_RESPONSE_FAIL`), and + /// [`crate::Cliprdr`] has already cleared + /// `local_file_list` / `local_file_list_format_id` per MS-RDPECLIP 3.1.5.2.4. + /// + /// Backends can use this to retry on `Fail` (e.g. ride out a transient + /// rejection caused by the remote window being inactive at the instant we + /// advertised) and, equally important, to **stop** re-advertising once an + /// `Ok` is seen — a later blind re-advertise that gets rejected would wipe + /// already-accepted state and silently break a paste that was about to work. + fn on_format_list_response(&mut self, ok: bool) { + let _ = ok; + } + /// Adjusts [crate::Cliprdr] backend capabilities based on capabilities negotiated with a server. /// /// Called by [crate::Cliprdr] when capability negotiation is finished and server capabilities are diff --git a/crates/ironrdp-cliprdr/src/lib.rs b/crates/ironrdp-cliprdr/src/lib.rs index 62a22638c8..2fa0df0095 100644 --- a/crates/ironrdp-cliprdr/src/lib.rs +++ b/crates/ironrdp-cliprdr/src/lib.rs @@ -627,6 +627,7 @@ impl Cliprdr { info!("Remote accepted format list"); } } + self.backend.on_format_list_response(true); } FormatListResponse::Fail => { // [MS-RDPECLIP] 3.1.5.2.4 - The remote rejected our FormatList but the @@ -654,6 +655,7 @@ impl Cliprdr { self.sent_file_contents_requests.clear(); } + self.backend.on_format_list_response(false); } } From 91ea46bd90cc3c7cfbbd4658d80aedb16844bbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Mon, 25 May 2026 23:38:03 +0900 Subject: [PATCH 246/325] fix(egfx)!: separate wire-level RawCapabilitySet from typed CapabilitySet (#1305) --- crates/ironrdp-egfx/src/client.rs | 43 +- crates/ironrdp-egfx/src/pdu/cmd.rs | 392 +++++++++++------- crates/ironrdp-egfx/src/server.rs | 34 +- crates/ironrdp-fuzzing/src/oracles/mod.rs | 6 +- .../src/graphics_messages.rs | 4 +- .../tests/egfx/capabilities.rs | 73 ++++ .../tests/egfx/client.rs | 15 +- .../ironrdp-testsuite-core/tests/egfx/mod.rs | 1 + .../tests/egfx/server.rs | 22 +- 9 files changed, 386 insertions(+), 204 deletions(-) create mode 100644 crates/ironrdp-testsuite-core/tests/egfx/capabilities.rs diff --git a/crates/ironrdp-egfx/src/client.rs b/crates/ironrdp-egfx/src/client.rs index 40749ed122..91dd87dfb0 100644 --- a/crates/ironrdp-egfx/src/client.rs +++ b/crates/ironrdp-egfx/src/client.rs @@ -68,8 +68,8 @@ use crate::pdu::{ Avc420BitmapStream, CacheImportReplyPdu, CacheToSurfacePdu, CapabilitiesAdvertisePdu, CapabilitiesV8Flags, CapabilitiesV81Flags, CapabilitiesV107Flags, CapabilitySet, Codec1Type, DeleteEncodingContextPdu, EvictCacheEntryPdu, FrameAcknowledgePdu, GfxPdu, MapSurfaceToScaledOutputPdu, MapSurfaceToScaledWindowPdu, - MapSurfaceToWindowPdu, PixelFormat, QueueDepth, SolidFillPdu, SurfaceToCachePdu, SurfaceToSurfacePdu, - WireToSurface2Pdu, + MapSurfaceToWindowPdu, PixelFormat, QueueDepth, RawCapabilitySet, SolidFillPdu, SurfaceToCachePdu, + SurfaceToSurfacePdu, WireToSurface2Pdu, }; /// Max capacity to keep for decompressed buffer when cleared. @@ -171,7 +171,6 @@ impl CodecCapabilities { small_cache: flags.contains(CapabilitiesV107Flags::SMALL_CACHE), thin_client: flags.contains(CapabilitiesV107Flags::AVC_THIN_CLIENT), }, - CapabilitySet::Unknown(_) => Self::default(), } } } @@ -581,10 +580,32 @@ impl GraphicsPipelineClient { } } - fn handle_capabilities_confirm(&mut self, cap: CapabilitySet) { + fn handle_capabilities_confirm(&mut self, cap: RawCapabilitySet) { + // Server confirms a single capability set. If we cannot interpret it + // (unknown version, or malformed body), we still transition to Active + // to avoid hanging the session, but we keep `negotiated_caps` empty + // and skip the typed callback so consumers don't observe a confirm + // they can't reason about. + let cap = match cap.parsed() { + Ok(Some(typed)) => typed, + Ok(None) => { + warn!( + version = cap.version.0, + "Server confirmed an unknown EGFX capability version; proceeding with defaults" + ); + self.state = ClientState::Active; + return; + } + Err(e) => { + warn!(error = %e, "Failed to parse server's EGFX capabilities confirmation"); + self.state = ClientState::Active; + return; + } + }; + self.codec_caps = CodecCapabilities::from_capability_set(&cap); - self.negotiated_caps = Some(cap.clone()); self.state = ClientState::Active; + let cap = self.negotiated_caps.insert(cap); debug!( avc420 = self.codec_caps.avc420, @@ -592,7 +613,7 @@ impl GraphicsPipelineClient { "EGFX capabilities confirmed" ); - self.handler.on_capabilities_confirmed(&cap); + self.handler.on_capabilities_confirmed(cap); } fn handle_reset_graphics(&mut self, width: u32, height: u32) { @@ -829,7 +850,7 @@ impl DvcProcessor for GraphicsPipelineClient { } }; - let pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(caps)); + let pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&caps)); #[expect(clippy::as_conversions, reason = "Box to Box coercion")] Ok(vec![Box::new(pdu) as DvcMessage]) @@ -972,11 +993,11 @@ mod tests { assert_eq!(client.state, ClientState::WaitingForConfirm); assert!(!client.is_active()); - let _ = client.handle_pdu(GfxPdu::CapabilitiesConfirm(crate::pdu::CapabilitiesConfirmPdu( - CapabilitySet::V8 { + let _ = client.handle_pdu(GfxPdu::CapabilitiesConfirm( + crate::pdu::CapabilitiesConfirmPdu::from_typed(&CapabilitySet::V8 { flags: CapabilitiesV8Flags::empty(), - }, - ))); + }), + )); assert_eq!(client.state, ClientState::Active); assert!(client.is_active()); diff --git a/crates/ironrdp-egfx/src/pdu/cmd.rs b/crates/ironrdp-egfx/src/pdu/cmd.rs index fd2360622c..19dded7059 100644 --- a/crates/ironrdp-egfx/src/pdu/cmd.rs +++ b/crates/ironrdp-egfx/src/pdu/cmd.rs @@ -1413,12 +1413,17 @@ impl<'a> Decode<'a> for CacheEntryMetadata { /// /// [2.2.2.18]: #[derive(Debug, Clone, PartialEq, Eq)] -pub struct CapabilitiesAdvertisePdu(pub Vec); +pub struct CapabilitiesAdvertisePdu(pub Vec); impl CapabilitiesAdvertisePdu { const NAME: &'static str = "CapabilitiesAdvertisePdu"; const FIXED_PART_SIZE: usize = 2 /* Count */; + + /// Build the PDU from a list of typed capability sets. + pub fn from_typed(caps: &[CapabilitySet]) -> Self { + Self(caps.iter().map(RawCapabilitySet::from).collect()) + } } impl Encode for CapabilitiesAdvertisePdu { @@ -1449,9 +1454,9 @@ impl<'a> Decode<'a> for CapabilitiesAdvertisePdu { let capabilities_count = cast_length!("Count", src.read_u16())?; - ensure_size!(in: src, size: capabilities_count * CapabilitySet::FIXED_PART_SIZE); + ensure_size!(in: src, size: capabilities_count * RawCapabilitySet::FIXED_PART_SIZE); - let capabilities = iter::repeat_with(|| CapabilitySet::decode(src)) + let capabilities = iter::repeat_with(|| RawCapabilitySet::decode(src)) .take(capabilities_count) .collect::>()?; @@ -1463,12 +1468,17 @@ impl<'a> Decode<'a> for CapabilitiesAdvertisePdu { /// /// [2.2.2.19]: #[derive(Debug, Clone, PartialEq, Eq)] -pub struct CapabilitiesConfirmPdu(pub CapabilitySet); +pub struct CapabilitiesConfirmPdu(pub RawCapabilitySet); impl CapabilitiesConfirmPdu { const NAME: &'static str = "CapabilitiesConfirmPdu"; const FIXED_PART_SIZE: usize = 0; + + /// Build the PDU from a typed capability set. + pub fn from_typed(cap: &CapabilitySet) -> Self { + Self(RawCapabilitySet::from(cap)) + } } impl Encode for CapabilitiesConfirmPdu { @@ -1493,16 +1503,164 @@ impl<'a> Decode<'a> for CapabilitiesConfirmPdu { fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let cap = CapabilitySet::decode(src)?; + let cap = RawCapabilitySet::decode(src)?; Ok(Self(cap)) } } -/// 2.2.1.6 RDPGFX_CAPSET +/// 2.2.1.6 RDPGFX_CAPSET — lossless wire-level representation. +/// +/// Stores the original `version` alongside the raw body bytes (`data`). This +/// is what [`CapabilitiesAdvertisePdu`] and [`CapabilitiesConfirmPdu`] hold on +/// the wire, ensuring that `m == encode(decode(m))` even when this build does +/// not recognize the advertised version. +/// +/// Use [`Self::parsed`] to obtain a typed [`CapabilitySet`] when the version +/// is known to this build. /// /// [2.2.1.6]: #[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawCapabilitySet { + pub version: CapabilityVersion, + pub data: Vec, +} + +impl RawCapabilitySet { + const NAME: &'static str = "GfxCapabilitySet"; + + const FIXED_PART_SIZE: usize = 4 /* version */ + 4 /* capsDataLength */; + + pub fn new(version: CapabilityVersion, data: Vec) -> Self { + Self { version, data } + } + + /// Parse the body into a typed [`CapabilitySet`] when the version is one + /// this build recognizes. Returns `Ok(None)` for unknown versions, leaving + /// the raw bytes available via [`Self::data`]. + pub fn parsed(&self) -> DecodeResult> { + let mut cur = ReadCursor::new(&self.data); + let cap = match self.version { + CapabilityVersion::V8 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V8 { + flags: CapabilitiesV8Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V8_1 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V8_1 { + flags: CapabilitiesV81Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10 { + flags: CapabilitiesV10Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_1 => { + ensure_size!(in: cur, size: 16); + cur.read_u128(); + CapabilitySet::V10_1 + } + CapabilityVersion::V10_2 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_2 { + flags: CapabilitiesV10Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_3 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_3 { + flags: CapabilitiesV103Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_4 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_4 { + flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_5 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_5 { + flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_6 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_6 { + flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_6_ERR => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_6Err { + flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_7 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_7 { + flags: CapabilitiesV107Flags::from_bits_retain(cur.read_u32()), + } + } + _ => return Ok(None), + }; + + Ok(Some(cap)) + } +} + +impl Encode for RawCapabilitySet { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u32(self.version.into()); + dst.write_u32(cast_length!("dataLength", self.data.len())?); + dst.write_slice(&self.data); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.data.len() + } +} + +impl<'de> Decode<'de> for RawCapabilitySet { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let version = CapabilityVersion(src.read_u32()); + let data_length: usize = cast_length!("dataLength", src.read_u32())?; + + ensure_size!(in: src, size: data_length); + let data = src.read_slice(data_length).to_vec(); + + // Tolerate capability versions this build doesn't recognize instead of + // failing the whole PDU. A strict error here would abort decoding of + // the entire CapabilitiesAdvertise during EGFX negotiation, which can + // prevent a connection from being established at all when a client + // advertises a capset version outside the set enumerated in + // `CapabilityVersion`. Preserving the raw bytes lets + // negotiation complete so the server can still select a mutually + // supported version. Use `RawCapabilitySet::parsed` to obtain a typed + // view when needed. + Ok(Self { version, data }) + } +} + +/// 2.2.1.6 RDPGFX_CAPSET — typed view of a [`RawCapabilitySet`] body. +/// +/// Holds only versions this build knows how to interpret. Obtained from +/// [`RawCapabilitySet::parsed`], which returns `None` for unknown versions. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum CapabilitySet { V8 { flags: CapabilitiesV8Flags }, V8_1 { flags: CapabilitiesV81Flags }, @@ -1515,15 +1673,11 @@ pub enum CapabilitySet { V10_6 { flags: CapabilitiesV104Flags }, V10_6Err { flags: CapabilitiesV104Flags }, V10_7 { flags: CapabilitiesV107Flags }, - Unknown(Vec), } impl CapabilitySet { - const NAME: &'static str = "GfxCapabilitySet"; - - const FIXED_PART_SIZE: usize = 4 /* version */ + 4 /* capsDataLength */; - - fn version(&self) -> CapabilityVersion { + /// Wire `version` field corresponding to this typed variant. + pub fn version(&self) -> CapabilityVersion { match self { CapabilitySet::V8 { .. } => CapabilityVersion::V8, CapabilitySet::V8_1 { .. } => CapabilityVersion::V8_1, @@ -1534,20 +1688,31 @@ impl CapabilitySet { CapabilitySet::V10_4 { .. } => CapabilityVersion::V10_4, CapabilitySet::V10_5 { .. } => CapabilityVersion::V10_5, CapabilitySet::V10_6 { .. } => CapabilityVersion::V10_6, - CapabilitySet::V10_6Err { .. } => CapabilityVersion::V10_6Err, + CapabilitySet::V10_6Err { .. } => CapabilityVersion::V10_6_ERR, CapabilitySet::V10_7 { .. } => CapabilityVersion::V10_7, - CapabilitySet::Unknown { .. } => CapabilityVersion::Unknown, } } -} - -impl Encode for CapabilitySet { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - dst.write_u32(self.version().into()); - dst.write_u32(cast_length!("dataLength", self.size() - Self::FIXED_PART_SIZE)?); + /// Size of the body bytes (no version/length header). + fn body_size(&self) -> usize { + match self { + CapabilitySet::V10_1 => 16, + CapabilitySet::V8 { .. } + | CapabilitySet::V8_1 { .. } + | CapabilitySet::V10 { .. } + | CapabilitySet::V10_2 { .. } + | CapabilitySet::V10_3 { .. } + | CapabilitySet::V10_4 { .. } + | CapabilitySet::V10_5 { .. } + | CapabilitySet::V10_6 { .. } + | CapabilitySet::V10_6Err { .. } + | CapabilitySet::V10_7 { .. } => 4, + } + } + /// Serialize just the body bytes (no version/length header). + fn write_body(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.body_size()); match self { CapabilitySet::V8 { flags } => dst.write_u32(flags.bits()), CapabilitySet::V8_1 { flags } => dst.write_u32(flags.bits()), @@ -1560,161 +1725,66 @@ impl Encode for CapabilitySet { CapabilitySet::V10_6 { flags } => dst.write_u32(flags.bits()), CapabilitySet::V10_6Err { flags } => dst.write_u32(flags.bits()), CapabilitySet::V10_7 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::Unknown(data) => dst.write_slice(data), } - Ok(()) } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - + match self { - CapabilitySet::V8 { .. } - | CapabilitySet::V8_1 { .. } - | CapabilitySet::V10 { .. } - | CapabilitySet::V10_2 { .. } - | CapabilitySet::V10_3 { .. } - | CapabilitySet::V10_4 { .. } - | CapabilitySet::V10_5 { .. } - | CapabilitySet::V10_6 { .. } - | CapabilitySet::V10_6Err { .. } - | CapabilitySet::V10_7 { .. } => 4, - CapabilitySet::V10_1 => 16, - CapabilitySet::Unknown(data) => data.len(), - } - } } -impl<'de> Decode<'de> for CapabilitySet { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let version_raw = src.read_u32(); - let data_length: usize = cast_length!("dataLength", src.read_u32())?; - - ensure_size!(in: src, size: data_length); - let data = src.read_slice(data_length); - - // Tolerate capability versions this build doesn't recognize instead of - // failing the whole PDU. A strict error here aborts decoding of the - // entire CapabilitiesAdvertise during EGFX negotiation, which can - // prevent a connection from being established at all when a client - // advertises a capset version outside the set enumerated below - // (observed with the macOS "Windows App" / Microsoft Remote Desktop - // client). Preserving the raw bytes as `Unknown` lets negotiation - // complete so the server can still select a mutually supported version. - let Ok(version) = CapabilityVersion::try_from(version_raw) else { - return Ok(CapabilitySet::Unknown(data.to_vec())); - }; - - let mut cur = ReadCursor::new(data); - - let size = match version { - CapabilityVersion::V8 - | CapabilityVersion::V8_1 - | CapabilityVersion::V10 - | CapabilityVersion::V10_2 - | CapabilityVersion::V10_3 - | CapabilityVersion::V10_4 - | CapabilityVersion::V10_5 - | CapabilityVersion::V10_6 - | CapabilityVersion::V10_6Err - | CapabilityVersion::V10_7 => 4, - CapabilityVersion::V10_1 => 16, - CapabilityVersion::Unknown => 0, - }; - - ensure_size!(in: cur, size: size); - match version { - CapabilityVersion::V8 => Ok(CapabilitySet::V8 { - flags: CapabilitiesV8Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V8_1 => Ok(CapabilitySet::V8_1 { - flags: CapabilitiesV81Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10 => Ok(CapabilitySet::V10 { - flags: CapabilitiesV10Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_1 => { - cur.read_u128(); - - Ok(CapabilitySet::V10_1) - } - CapabilityVersion::V10_2 => Ok(CapabilitySet::V10_2 { - flags: CapabilitiesV10Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_3 => Ok(CapabilitySet::V10_3 { - flags: CapabilitiesV103Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_4 => Ok(CapabilitySet::V10_4 { - flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_5 => Ok(CapabilitySet::V10_5 { - flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_6 => Ok(CapabilitySet::V10_6 { - flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_6Err => Ok(CapabilitySet::V10_6Err { - flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::V10_7 => Ok(CapabilitySet::V10_7 { - flags: CapabilitiesV107Flags::from_bits_retain(cur.read_u32()), - }), - CapabilityVersion::Unknown => Ok(CapabilitySet::Unknown(data.to_vec())), +impl From<&CapabilitySet> for RawCapabilitySet { + fn from(cap: &CapabilitySet) -> Self { + let mut data = vec![0u8; cap.body_size()]; + let mut cur = WriteCursor::new(&mut data); + cap.write_body(&mut cur) + .expect("buffer is sized to body_size; write cannot fail"); + Self { + version: cap.version(), + data, } } } -#[repr(u32)] +/// Capability set version, as advertised in 2.2.1.6 RDPGFX_CAPSET. #[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub(crate) enum CapabilityVersion { - V8 = 0x8_0004, - V8_1 = 0x8_0105, - V10 = 0xa_0002, - V10_1 = 0xa_0100, - V10_2 = 0xa_0200, - V10_3 = 0xa_0301, - V10_4 = 0xa_0400, - V10_5 = 0xa_0502, - V10_6 = 0xa_0600, // [MS-RDPEGFX-errata] - V10_6Err = 0xa_0601, // defined similar to FreeRDP to maintain best compatibility - V10_7 = 0xa_0701, - Unknown = 0xa_0702, -} - -impl TryFrom for CapabilityVersion { - type Error = DecodeError; - - fn try_from(value: u32) -> Result { - let res = match value { - 0x8_0004 => CapabilityVersion::V8, - 0x8_0105 => CapabilityVersion::V8_1, - 0xa_0002 => CapabilityVersion::V10, - 0xa_0100 => CapabilityVersion::V10_1, - 0xa_0200 => CapabilityVersion::V10_2, - 0xa_0301 => CapabilityVersion::V10_3, - 0xa_0400 => CapabilityVersion::V10_4, - 0xa_0502 => CapabilityVersion::V10_5, - 0xa_0600 => CapabilityVersion::V10_6, - 0xa_0601 => CapabilityVersion::V10_6Err, - 0xa_0701 => CapabilityVersion::V10_7, - 0xa_0702 => CapabilityVersion::Unknown, - _ => return Err(invalid_field_err!("version", "invalid capability version")), - }; - - Ok(res) +pub struct CapabilityVersion(pub u32); + +impl CapabilityVersion { + pub const V8: Self = Self(0x8_0004); + pub const V8_1: Self = Self(0x8_0105); + pub const V10: Self = Self(0xa_0002); + pub const V10_1: Self = Self(0xa_0100); + pub const V10_2: Self = Self(0xa_0200); + pub const V10_3: Self = Self(0xa_0301); + pub const V10_4: Self = Self(0xa_0400); + pub const V10_5: Self = Self(0xa_0502); + pub const V10_6: Self = Self(0xa_0600); // [MS-RDPEGFX-errata] + pub const V10_6_ERR: Self = Self(0xa_0601); // defined similar to FreeRDP to maintain best compatibility + pub const V10_7: Self = Self(0xa_0701); + + /// Returns `true` if this version matches one of the constants defined on + /// `CapabilityVersion`, i.e. one this build knows how to decode into a + /// dedicated `CapabilitySet` variant. + #[must_use] + pub fn is_known(self) -> bool { + matches!( + self, + Self::V8 + | Self::V8_1 + | Self::V10 + | Self::V10_1 + | Self::V10_2 + | Self::V10_3 + | Self::V10_4 + | Self::V10_5 + | Self::V10_6 + | Self::V10_6_ERR + | Self::V10_7 + ) } } impl From for u32 { - #[expect(clippy::as_conversions, reason = "repr(u32) enum discriminant")] fn from(value: CapabilityVersion) -> Self { - value as u32 + value.0 } } diff --git a/crates/ironrdp-egfx/src/server.rs b/crates/ironrdp-egfx/src/server.rs index fed495a4a0..4ba3fe2517 100644 --- a/crates/ironrdp-egfx/src/server.rs +++ b/crates/ironrdp-egfx/src/server.rs @@ -674,7 +674,6 @@ impl CodecCapabilities { small_cache: flags.contains(CapabilitiesV107Flags::SMALL_CACHE), thin_client: flags.contains(CapabilitiesV107Flags::AVC_THIN_CLIENT), }, - CapabilitySet::Unknown(_) => Self::default(), } } } @@ -693,7 +692,6 @@ fn capability_priority(cap: &CapabilitySet) -> u32 { CapabilitySet::V10 { .. } => 4, CapabilitySet::V8_1 { .. } => 3, CapabilitySet::V8 { .. } => 2, - _ => 0, } } @@ -742,7 +740,7 @@ fn intersect_flags(client: &CapabilitySet, server: &CapabilitySet) -> Capability (CapabilitySet::V10_7 { flags: cf }, CapabilitySet::V10_7 { flags: sf }) => { CapabilitySet::V10_7 { flags: *cf & *sf } } - // V10_1 has no flags; Unknown and mismatched variants return server as-is. + // V10_1 has no flags; mismatched variants return server as-is. _ => server.clone(), } } @@ -1637,12 +1635,28 @@ impl GraphicsPipelineServer { self.handler.capabilities_advertise(&pdu); let server_caps = self.handler.preferred_capabilities(); + // Parse client raw caps into typed. Silently skip unknown versions for + // negotiation purposes (the raw form is still observable in `pdu`), but + // treat parse failures for known versions as malformed input instead of + // negotiating as if the client never advertised them. + let mut client_caps = Vec::with_capacity(pdu.0.len()); + for raw in &pdu.0 { + match raw.parsed() { + Ok(Some(cap)) => client_caps.push(cap), + Ok(None) => {} + Err(e) => { + warn!(error = ?e, "Received malformed client capability set; aborting capability negotiation"); + return; + } + } + } + // When no version overlaps with server preferences, confirm the client's // highest-priority capability to avoid confirming a version the client // did not advertise. - let negotiated = negotiate_capabilities(&pdu.0, &server_caps).unwrap_or_else(|| { + let negotiated = negotiate_capabilities(&client_caps, &server_caps).unwrap_or_else(|| { warn!("No capability match with server preferences, selecting client's highest version"); - let mut client_sorted = pdu.0.clone(); + let mut client_sorted = client_caps.clone(); client_sorted.sort_by_key(|cap| core::cmp::Reverse(capability_priority(cap))); client_sorted.into_iter().next().unwrap_or(CapabilitySet::V8 { flags: CapabilitiesV8Flags::empty(), @@ -1650,13 +1664,15 @@ impl GraphicsPipelineServer { }); self.codec_caps = CodecCapabilities::from_capability_set(&negotiated); - self.negotiated_caps = Some(negotiated.clone()); + self.state = ServerState::Ready; + let negotiated = self.negotiated_caps.insert(negotiated); self.output_queue - .push_back(GfxPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu(negotiated.clone()))); + .push_back(GfxPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu::from_typed( + negotiated, + ))); - self.state = ServerState::Ready; - self.handler.on_ready(&negotiated); + self.handler.on_ready(negotiated); } fn handle_frame_acknowledge(&mut self, pdu: FrameAcknowledgePdu) { diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index 74cdf424e6..19f16f85b9 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -113,8 +113,8 @@ pub fn bulk_round_trip(data: &[u8]) { pub fn pdu_decode(data: &[u8]) { use ironrdp_core::decode; use ironrdp_egfx::pdu::{ - Avc420BitmapStream, Avc444BitmapStream, CacheToSurfacePdu, CapabilitySet as EgfxCapabilitySet, Color, GfxPdu, - Point, QuantQuality, + Avc420BitmapStream, Avc444BitmapStream, CacheToSurfacePdu, Color, GfxPdu, Point, QuantQuality, + RawCapabilitySet as EgfxRawCapabilitySet, }; use ironrdp_pdu::mcs::{ConnectInitial, ConnectResponse, McsMessage}; use ironrdp_pdu::nego::{ConnectionConfirm, ConnectionRequest}; @@ -185,7 +185,7 @@ pub fn pdu_decode(data: &[u8]) { let _ = decode::(data); let _ = decode::(data); - let _ = decode::(data); + let _ = decode::(data); let _ = decode::>(data); let _ = decode::>(data); let _ = decode::(data); diff --git a/crates/ironrdp-testsuite-core/src/graphics_messages.rs b/crates/ironrdp-testsuite-core/src/graphics_messages.rs index b76c7337d7..1f7892c1fc 100644 --- a/crates/ironrdp-testsuite-core/src/graphics_messages.rs +++ b/crates/ironrdp-testsuite-core/src/graphics_messages.rs @@ -337,12 +337,12 @@ pub static START_FRAME: LazyLock = LazyLock::new(|| StartFramePdu }); pub static END_FRAME: LazyLock = LazyLock::new(|| EndFramePdu { frame_id: 1 }); pub static CAPABILITIES_CONFIRM: LazyLock = LazyLock::new(|| { - CapabilitiesConfirmPdu(CapabilitySet::V10_5 { + CapabilitiesConfirmPdu::from_typed(&CapabilitySet::V10_5 { flags: CapabilitiesV104Flags::AVC_DISABLED, }) }); pub static CAPABILITIES_ADVERTISE: LazyLock = LazyLock::new(|| { - CapabilitiesAdvertisePdu(vec![ + CapabilitiesAdvertisePdu::from_typed(&[ CapabilitySet::V8 { flags: CapabilitiesV8Flags::THIN_CLIENT, }, diff --git a/crates/ironrdp-testsuite-core/tests/egfx/capabilities.rs b/crates/ironrdp-testsuite-core/tests/egfx/capabilities.rs new file mode 100644 index 0000000000..67cf57a672 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/egfx/capabilities.rs @@ -0,0 +1,73 @@ +//! Tests for resilient capability-set decoding, per the +//! "Enumeration-like types should allow resilient parsing" section of +//! `crates/ironrdp-pdu/README.md`. +//! +//! Two properties matter: +//! +//! - an unrecognized `CapabilityVersion` decodes into a +//! `RawCapabilitySet` whose `.parsed()` returns `None`, instead of +//! failing the whole PDU; +//! - the original wire bytes (including the version value) are preserved so +//! that `encode(decode(m)) == m`. + +use ironrdp_core::{decode, encode_vec}; +use ironrdp_egfx::pdu::{CapabilitiesAdvertisePdu, CapabilityVersion}; +use proptest::{prelude::*, sample::select}; + +/// Build a raw `RDPGFX_CAPS_ADVERTISE_PDU` carrying a single capset: +/// `capsSetCount=1` then `version`, `dataLength`, `data`. +fn raw_advertise_one(version: u32, data: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(2 + 8 + data.len()); + buf.extend_from_slice(&1u16.to_le_bytes()); + buf.extend_from_slice(&version.to_le_bytes()); + let len = u32::try_from(data.len()).expect("test data length fits u32"); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(data); + buf +} + +/// Choose a random version with a bias towards well-known ones. +fn version() -> impl Strategy { + prop_oneof![ + select::(&[ + CapabilityVersion::V8.0, + CapabilityVersion::V8_1.0, + CapabilityVersion::V10.0, + CapabilityVersion::V10_1.0, + CapabilityVersion::V10_2.0, + CapabilityVersion::V10_3.0, + CapabilityVersion::V10_4.0, + CapabilityVersion::V10_5.0, + CapabilityVersion::V10_6.0, + CapabilityVersion::V10_6_ERR.0, + CapabilityVersion::V10_7.0, + ]), + any::(), + ] +} + +/// `encode(decode(wire)) == wire` for any version and any payload. +#[test] +fn capability_set_roundtrips() { + proptest!(|( + version in version(), + data in proptest::collection::vec(any::(), 0..32usize), + )| { + let wire = raw_advertise_one(version, &data); + let pdu: CapabilitiesAdvertisePdu = decode(&wire).expect("decode must tolerate unknown version"); + + let cap = &pdu.0[0]; + if cap.version.is_known() { + // `parsed()` may fail because of invalid data format (length, etc) for known versions. + if let Ok(parsed) = cap.parsed() { + prop_assert!(parsed.is_some()); + } + } else { + // `parsed()` never fails for unknown versions. + prop_assert!(cap.parsed().expect("parsed never errors for unknown versions").is_none()); + } + + let re_encoded = encode_vec(&pdu).expect("encode must succeed"); + prop_assert_eq!(re_encoded, wire); + }); +} diff --git a/crates/ironrdp-testsuite-core/tests/egfx/client.rs b/crates/ironrdp-testsuite-core/tests/egfx/client.rs index 7cb619ff17..44bb079e49 100644 --- a/crates/ironrdp-testsuite-core/tests/egfx/client.rs +++ b/crates/ironrdp-testsuite-core/tests/egfx/client.rs @@ -3,8 +3,9 @@ use ironrdp_dvc::DvcProcessor as _; use ironrdp_egfx::client::{BitmapUpdate, GraphicsPipelineClient, GraphicsPipelineHandler, Surface}; use ironrdp_egfx::decode::{DecodedFrame, DecoderResult, H264Decoder}; use ironrdp_egfx::pdu::{ - CapabilitiesAdvertisePdu, CapabilitiesConfirmPdu, CapabilitiesV8Flags, CapabilitySet, Codec1Type, CreateSurfacePdu, - DeleteSurfacePdu, EndFramePdu, GfxPdu, PixelFormat, ResetGraphicsPdu, StartFramePdu, Timestamp, WireToSurface1Pdu, + CapabilitiesAdvertisePdu, CapabilitiesConfirmPdu, CapabilitiesV8Flags, CapabilitySet, CapabilityVersion, + Codec1Type, CreateSurfacePdu, DeleteSurfacePdu, EndFramePdu, GfxPdu, PixelFormat, ResetGraphicsPdu, StartFramePdu, + Timestamp, WireToSurface1Pdu, }; use ironrdp_graphics::zgfx::wrap_uncompressed; use ironrdp_pdu::geometry::ExclusiveRectangle; @@ -113,7 +114,7 @@ fn setup_active_client_with_surface( let mut client = GraphicsPipelineClient::new(Box::new(handler), decoder); // Activate via CapabilitiesConfirm - let confirm = GfxPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu(CapabilitySet::V8 { + let confirm = GfxPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu::from_typed(&CapabilitySet::V8 { flags: CapabilitiesV8Flags::empty(), })); client @@ -160,7 +161,7 @@ fn client_filters_avc_caps_without_decoder() { "expected exactly one capability set when no decoder is present" ); assert!( - matches!(caps_pdu.0[0], CapabilitySet::V8 { .. }), + caps_pdu.0[0].version == CapabilityVersion::V8, "expected only V8 capability set without decoder, got {:?}", caps_pdu.0[0] ); @@ -179,9 +180,9 @@ fn client_keeps_avc_caps_with_decoder() { 3, "expected all three capability sets with decoder present" ); - assert!(matches!(caps_pdu.0[0], CapabilitySet::V10_7 { .. })); - assert!(matches!(caps_pdu.0[1], CapabilitySet::V8_1 { .. })); - assert!(matches!(caps_pdu.0[2], CapabilitySet::V8 { .. })); + assert_eq!(caps_pdu.0[0].version, CapabilityVersion::V10_7); + assert_eq!(caps_pdu.0[1].version, CapabilityVersion::V8_1); + assert_eq!(caps_pdu.0[2].version, CapabilityVersion::V8); } // ============================================================================ diff --git a/crates/ironrdp-testsuite-core/tests/egfx/mod.rs b/crates/ironrdp-testsuite-core/tests/egfx/mod.rs index d44a4d2561..579c13bf9c 100644 --- a/crates/ironrdp-testsuite-core/tests/egfx/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/egfx/mod.rs @@ -1,3 +1,4 @@ +mod capabilities; mod client; #[cfg(feature = "openh264-bundled")] mod decode; diff --git a/crates/ironrdp-testsuite-core/tests/egfx/server.rs b/crates/ironrdp-testsuite-core/tests/egfx/server.rs index 7a4cf370eb..086eba9cfa 100644 --- a/crates/ironrdp-testsuite-core/tests/egfx/server.rs +++ b/crates/ironrdp-testsuite-core/tests/egfx/server.rs @@ -86,7 +86,7 @@ fn test_capability_negotiation_v8() { let mut server = GraphicsPipelineServer::new(handler); // Simulate client sending CapabilitiesAdvertise - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V8 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8 { flags: CapabilitiesV8Flags::SMALL_CACHE, }])); @@ -105,7 +105,7 @@ fn test_capability_negotiation_v81_avc420() { let handler = Box::new(TestHandler::new()); let mut server = GraphicsPipelineServer::new(handler); - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V8_1 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8_1 { flags: CapabilitiesV81Flags::AVC420_ENABLED | CapabilitiesV81Flags::SMALL_CACHE, }])); @@ -122,7 +122,7 @@ fn test_capability_negotiation_v10_avc444() { let handler = Box::new(TestHandler::new()); let mut server = GraphicsPipelineServer::new(handler); - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V10 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V10 { flags: CapabilitiesV10Flags::SMALL_CACHE, }])); @@ -153,7 +153,7 @@ fn test_surface_lifecycle() { let mut server = GraphicsPipelineServer::new(handler); // Negotiate capabilities first - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V8_1 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8_1 { flags: CapabilitiesV81Flags::AVC420_ENABLED, }])); let payload = encode_pdu(&client_caps_pdu); @@ -191,7 +191,7 @@ fn test_resize() { let mut server = GraphicsPipelineServer::new(handler); // Negotiate capabilities - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V8 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8 { flags: CapabilitiesV8Flags::SMALL_CACHE, }])); let payload = encode_pdu(&client_caps_pdu); @@ -220,7 +220,7 @@ fn test_frame_flow_control() { server.set_max_frames_in_flight(2); // Negotiate capabilities with AVC420 - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V8_1 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8_1 { flags: CapabilitiesV81Flags::AVC420_ENABLED, }])); let payload = encode_pdu(&client_caps_pdu); @@ -270,7 +270,7 @@ fn test_qoe_snapshot_after_frame_ack() { let mut server = GraphicsPipelineServer::new(handler); // Negotiate capabilities. - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V8_1 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8_1 { flags: CapabilitiesV81Flags::AVC420_ENABLED, }])); let payload = encode_pdu(&client_caps_pdu); @@ -314,7 +314,7 @@ fn test_qoe_snapshot_after_qoe_report() { let mut server = GraphicsPipelineServer::new(handler); // Negotiate capabilities (V10 for QoE support). - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V10 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V10 { flags: CapabilitiesV10Flags::SMALL_CACHE, }])); let payload = encode_pdu(&client_caps_pdu); @@ -349,7 +349,7 @@ fn test_qoe_reset() { let mut server = GraphicsPipelineServer::new(handler); // Negotiate. - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V10 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V10 { flags: CapabilitiesV10Flags::SMALL_CACHE, }])); let payload = encode_pdu(&client_caps_pdu); @@ -381,7 +381,7 @@ fn test_send_uncompressed_frame_queues_correctly() { let mut server = GraphicsPipelineServer::new(handler); // V8 client: EGFX but no H.264 - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V8 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8 { flags: CapabilitiesV8Flags::SMALL_CACHE, }])); let payload = encode_pdu(&client_caps_pdu); @@ -407,7 +407,7 @@ fn test_send_uncompressed_frame_backpressure() { let mut server = GraphicsPipelineServer::new(handler); server.set_max_frames_in_flight(1); - let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu(vec![CapabilitySet::V8 { + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8 { flags: CapabilitiesV8Flags::SMALL_CACHE, }])); let payload = encode_pdu(&client_caps_pdu); From 879ffed866c32748d30d26b54f9d667ad001c51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Tue, 26 May 2026 00:00:02 +0900 Subject: [PATCH 247/325] feat(client): add --desktop-width and --desktop-height CLI options (#1307) Allow specifying the desired desktop resolution for the RDP session directly from the command line, mapping to the `desktopwidth` and `desktopheight` property set entries. --- crates/ironrdp-client/src/config.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 9afafd1531..4dcdbc20ae 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -81,6 +81,14 @@ fn apply_cli_args_to_properties(properties: &mut ironrdp_propertyset::PropertySe properties.insert("desktopscalefactor", i64::from(scale)); } + if let Some(width) = args.desktop_width { + properties.insert("desktopwidth", i64::from(width)); + } + + if let Some(height) = args.desktop_height { + properties.insert("desktopheight", i64::from(height)); + } + if let Some(gw_host) = &args.gw_endpoint { properties.insert("gatewayhostname", gw_host.as_str()); // Ensure the gateway is treated as enabled when a host is provided explicitly. @@ -337,6 +345,14 @@ struct Args { #[clap(long, value_parser = clap::value_parser!(u32).range(100..=500))] scale_desktop: Option, + /// Desired desktop width for the RDP session + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + desktop_width: Option, + + /// Desired desktop height for the RDP session + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + desktop_height: Option, + /// Set required color depth. Currently only 32 and 16 bit color depths are supported #[clap(long)] color_depth: Option, From 9d12adab8e930466fd10930170c6f77587e35e0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 16:37:28 +0900 Subject: [PATCH 248/325] build(deps): bump log from 0.4.29 to 0.4.30 in the patch group across 1 directory (#1310) --- Cargo.lock | 4 ++-- fuzz/Cargo.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4557fc3532..e85a28cf51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3242,9 +3242,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru-slab" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 74a8a2935a..8d6d678577 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -476,9 +476,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "md-5" From 196d18dfaa7ec899946bb90f4dcb8bad31872f48 Mon Sep 17 00:00:00 2001 From: uchouT Date: Tue, 26 May 2026 15:54:46 +0800 Subject: [PATCH 249/325] feat(dvc): close channel API for server and client (#1302) --- Cargo.lock | 356 +++++++++++++++---------------- crates/ironrdp-dvc/Cargo.toml | 1 - crates/ironrdp-dvc/src/client.rs | 44 ++-- crates/ironrdp-dvc/src/lib.rs | 22 +- crates/ironrdp-dvc/src/server.rs | 155 ++++++++++---- fuzz/Cargo.lock | 7 - 6 files changed, 335 insertions(+), 250 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e85a28cf51..12f3af5d65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,9 +253,9 @@ checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -347,15 +347,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -363,9 +363,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -518,9 +518,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -609,9 +609,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -862,9 +862,9 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.14.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15c3c3cee7c087938f7ad1c3098840b3ef1f1bdc7f6e496336c3b1e7a6f3914" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ "bitflags 2.11.1", "libc", @@ -1102,10 +1102,13 @@ dependencies = [ ] [[package]] -name = "ctor-lite" -version = "0.1.2" +name = "ctor" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] [[package]] name = "ctr" @@ -1167,9 +1170,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" @@ -1432,6 +1435,12 @@ dependencies = [ "linux-raw-sys 0.9.4", ] +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + [[package]] name = "dunce" version = "1.0.5" @@ -1485,9 +1494,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" @@ -1554,9 +1563,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fdeflate" @@ -1767,9 +1776,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" @@ -1930,9 +1939,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1969,9 +1978,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heapless" @@ -2063,9 +2072,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a79f2aff40c18ab8615ddc5caa9eb5b96314aef18fe5823090f204ad988e813" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "subtle", "typenum", @@ -2095,15 +2104,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -2177,12 +2185,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2190,9 +2199,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2203,9 +2212,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2217,15 +2226,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -2237,15 +2246,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2269,9 +2278,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2292,9 +2301,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", @@ -2330,16 +2339,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "iron-remote-desktop" version = "0.7.0" @@ -2378,7 +2377,7 @@ dependencies = [ "ironrdp-svc", "opus2", "pico-args", - "rand 0.9.2", + "rand 0.9.4", "sspi", "tokio-rustls", "tracing", @@ -2537,7 +2536,7 @@ dependencies = [ "picky", "picky-asn1-der", "picky-asn1-x509", - "rand 0.9.2", + "rand 0.9.4", "sspi", "tracing", "url", @@ -2568,7 +2567,6 @@ dependencies = [ "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", - "slab", "tracing", ] @@ -3120,9 +3118,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -3187,14 +3185,14 @@ dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.5", ] [[package]] name = "libz-sys" -version = "1.1.25" +version = "1.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" dependencies = [ "cc", "pkg-config", @@ -3221,9 +3219,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -3451,9 +3449,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -3889,15 +3887,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags 2.11.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -3921,9 +3918,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -3942,9 +3939,9 @@ dependencies = [ [[package]] name = "orbclient" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ "libc", "libredox", @@ -4220,18 +4217,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -4282,9 +4279,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -4364,14 +4361,14 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" dependencies = [ - "rand 0.8.5", + "rand 0.8.6", ] [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -4457,7 +4454,7 @@ dependencies = [ "bit-vec", "bitflags 2.11.1", "num-traits", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -4468,9 +4465,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "qoicoubeh" @@ -4489,9 +4486,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] @@ -4525,7 +4522,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -4574,9 +4571,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4585,9 +4582,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4712,9 +4709,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.11.1", ] @@ -4960,9 +4957,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -4997,9 +4994,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -5007,9 +5004,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -5187,9 +5184,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -5221,9 +5218,9 @@ dependencies = [ [[package]] name = "serdect" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af4a3e75ebd5599b30d4de5768e00b5095d518a79fefc3ecbaf77e665d1ec06" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ "base16ct", "serde", @@ -5765,12 +5762,12 @@ dependencies = [ [[package]] name = "tiny-xlib" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" dependencies = [ "as-raw-xcb-connection", - "ctor-lite", + "ctor", "libloading", "pkg-config", "tracing", @@ -5784,9 +5781,9 @@ checksum = "9ab95735ea2c8fd51154d01e39cf13912a78071c2d89abc49a7ef102a7dd725a" [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -5943,9 +5940,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -5985,20 +5982,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -6128,7 +6125,7 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.9.2", + "rand 0.9.4", "rustls", "rustls-pki-types", "sha1 0.10.6", @@ -6137,9 +6134,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unarray" @@ -6311,9 +6308,9 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ "wit-bindgen", ] @@ -6329,9 +6326,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -6342,9 +6339,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.65" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -6352,9 +6349,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6362,9 +6359,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -6375,18 +6372,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] [[package]] name = "wayland-backend" -version = "0.3.14" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -6398,9 +6395,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.13" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ "bitflags 2.11.1", "rustix 1.1.4", @@ -6421,9 +6418,9 @@ dependencies = [ [[package]] name = "wayland-cursor" -version = "0.31.13" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ "rustix 1.1.4", "wayland-client", @@ -6432,9 +6429,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.11" +version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ "bitflags 2.11.1", "wayland-backend", @@ -6444,9 +6441,9 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ "bitflags 2.11.1", "wayland-backend", @@ -6457,9 +6454,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ "bitflags 2.11.1", "wayland-backend", @@ -6470,9 +6467,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.9" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", "quick-xml", @@ -6481,9 +6478,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -6493,9 +6490,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.92" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -6513,9 +6510,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -6986,9 +6983,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -7005,9 +7002,9 @@ dependencies = [ [[package]] name = "winscard" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4395eea3e74c69a89c5b9fd63d7b7446ddbcf8d7381b70791f8e28939dbef18b" +checksum = "339bcf57dd0c2341c7ac559b146a4d7e35378cefe3d8729bf028820e856bd578" dependencies = [ "bitflags 2.11.1", "crypto-bigint", @@ -7023,19 +7020,20 @@ dependencies = [ "time", "tracing", "uuid", + "widestring", ] [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -7159,9 +7157,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7170,9 +7168,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -7211,18 +7209,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -7252,9 +7250,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -7263,9 +7261,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -7274,9 +7272,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/crates/ironrdp-dvc/Cargo.toml b/crates/ironrdp-dvc/Cargo.toml index 0c61582a5c..1dd8f51640 100644 --- a/crates/ironrdp-dvc/Cargo.toml +++ b/crates/ironrdp-dvc/Cargo.toml @@ -25,7 +25,6 @@ ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["alloc"] } # public tracing = { version = "0.1", features = ["log"] } -slab = "0.4" [lints] workspace = true diff --git a/crates/ironrdp-dvc/src/client.rs b/crates/ironrdp-dvc/src/client.rs index 6d70c84c9a..4a2001c4a4 100644 --- a/crates/ironrdp-dvc/src/client.rs +++ b/crates/ironrdp-dvc/src/client.rs @@ -167,6 +167,11 @@ impl DrdynvcClient { self.cap_handshake_done = true; SvcMessage::from(caps_response) } + + pub fn close_channel(&mut self, channel_id: u32) -> Option { + self.dynamic_channels.remove_by_channel_id(channel_id)?; + Some(SvcMessage::from(DrdynvcClientPdu::Close(ClosePdu::new(channel_id)))) + } } impl_as_any!(DrdynvcClient); @@ -210,7 +215,17 @@ impl SvcProcessor for DrdynvcClient { let (creation_status, start_messages) = if let Some(dvc) = self.dynamic_channels.try_create_channel(&channel_name, channel_id) { - (CreationStatus::OK, dvc.start()?) + match dvc.start(channel_id) { + Ok(messages) => (CreationStatus::OK, messages), + Err(e) => { + debug!( + ?channel_id, error = %e, + "DVC start failed; removing channel and reporting NO_LISTENER" + ); + self.dynamic_channels.remove_by_channel_id(channel_id); + (CreationStatus::NO_LISTENER, Vec::new()) + } + } } else { (CreationStatus::NO_LISTENER, Vec::new()) }; @@ -227,14 +242,14 @@ impl SvcProcessor for DrdynvcClient { ); } } - DrdynvcServerPdu::Close(close_request) => { - debug!("Got DVC Close Request PDU: {close_request:?}"); - self.dynamic_channels.remove_by_channel_id(close_request.channel_id()); - - let close_response = DrdynvcClientPdu::Close(ClosePdu::new(close_request.channel_id())); - - debug!("Send DVC Close Response PDU: {close_response:?}"); - responses.push(SvcMessage::from(close_response)); + DrdynvcServerPdu::Close(close) => { + debug!("Got DVC Close PDU: {close:?}"); + let channel_id = close.channel_id(); + if self.dynamic_channels.remove_by_channel_id(channel_id).is_some() { + let close_response = DrdynvcClientPdu::Close(ClosePdu::new(channel_id)); + debug!("Send DVC Close Response PDU: {close_response:?}"); + responses.push(SvcMessage::from(close_response)); + } } DrdynvcServerPdu::Data(data) => { let channel_id = data.channel_id(); @@ -311,8 +326,9 @@ impl DynamicChannelSet { self.type_id_to_channel_id.insert(type_id, channel_id); } - let mut dvc = DynamicVirtualChannel::from_boxed(processor); - dvc.channel_id = Some(channel_id); + let dvc = DynamicVirtualChannel::from_boxed(processor); + // `dvc.channel_id` stays `None` here — it is set by `DynamicVirtualChannel::start` + // on success, so `Drop` only invokes `close` for channels that were actually opened. let dvc = match self.active_channels.entry(channel_id) { alloc::collections::btree_map::Entry::Occupied(mut e) => { e.insert(dvc); @@ -337,8 +353,8 @@ impl DynamicChannelSet { self.active_channels.get_mut(&id) } - fn remove_by_channel_id(&mut self, id: DynamicChannelId) { - if let Some(dvc) = self.active_channels.remove(&id) { + fn remove_by_channel_id(&mut self, id: DynamicChannelId) -> Option { + self.active_channels.remove(&id).inspect(|dvc| { let type_id = dvc.processor_type_id(); // Only matters for pre-registered channels @@ -347,7 +363,7 @@ impl DynamicChannelSet { { entry.remove(); } - } + }) } #[inline] diff --git a/crates/ironrdp-dvc/src/lib.rs b/crates/ironrdp-dvc/src/lib.rs index 2794523dde..6126653b0e 100644 --- a/crates/ironrdp-dvc/src/lib.rs +++ b/crates/ironrdp-dvc/src/lib.rs @@ -16,7 +16,7 @@ use pdu::DrdynvcDataPdu; #[rustfmt::skip] // do not re-order this pub use pub use ironrdp_pdu; use ironrdp_core::{AsAny, Encode, EncodeResult, assert_obj_safe, cast_length, encode_vec, other_err}; -use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; +use ironrdp_pdu::{PduResult, decode_err}; use ironrdp_svc::SvcMessage; mod complete_data; @@ -105,10 +105,18 @@ pub struct DynamicVirtualChannel { complete_data: CompleteData, /// The channel ID assigned by the server. /// - /// This field is `None` until the server assigns a channel ID. + /// `Some` only after [`DynamicVirtualChannel::start`] has succeeded. This invariant channel_id: Option, } +impl Drop for DynamicVirtualChannel { + fn drop(&mut self) { + if let Some(id) = self.channel_id { + self.channel_processor.close(id); + } + } +} + impl DynamicVirtualChannel { fn from_boxed(processor: Box) -> Self { Self { @@ -134,12 +142,10 @@ impl DynamicVirtualChannel { self.channel_processor.as_any().downcast_ref() } - fn start(&mut self) -> PduResult> { - if let Some(channel_id) = self.channel_id { - self.channel_processor.start(channel_id) - } else { - Err(pdu_other_err!("DynamicVirtualChannel::start", "channel ID not set")) - } + fn start(&mut self, channel_id: DynamicChannelId) -> PduResult> { + let messages = self.channel_processor.start(channel_id)?; + self.channel_id = Some(channel_id); + Ok(messages) } fn process(&mut self, pdu: DrdynvcDataPdu) -> PduResult> { diff --git a/crates/ironrdp-dvc/src/server.rs b/crates/ironrdp-dvc/src/server.rs index 80bc730169..21e905e681 100644 --- a/crates/ironrdp-dvc/src/server.rs +++ b/crates/ironrdp-dvc/src/server.rs @@ -4,16 +4,15 @@ use alloc::vec::Vec; use core::any::TypeId; use core::fmt; -use ironrdp_core::{Decode as _, DecodeResult, ReadCursor, cast_length, impl_as_any, invalid_field_err}; +use ironrdp_core::{Decode as _, DecodeResult, ReadCursor, impl_as_any, invalid_field_err}; use ironrdp_pdu::{self as pdu, decode_err, encode_err, pdu_other_err}; use ironrdp_svc::{ChannelFlags, CompressionCondition, SvcMessage, SvcProcessor, SvcServerProcessor}; use pdu::PduResult; use pdu::gcc::ChannelName; -use slab::Slab; use tracing::debug; use crate::pdu::{ - CapabilitiesRequestPdu, CapsVersion, CreateRequestPdu, CreationStatus, DrdynvcClientPdu, DrdynvcServerPdu, + CapabilitiesRequestPdu, CapsVersion, ClosePdu, CreateRequestPdu, CreationStatus, DrdynvcClientPdu, DrdynvcServerPdu, }; use crate::{CompleteData, DvcProcessor, encode_dvc_messages}; @@ -21,7 +20,8 @@ pub trait DvcServerProcessor: DvcProcessor {} #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum ChannelState { - Closed, + Pending, + /// `Create Request` has been sent; awaiting `Create Response` from the client. Creation, Opened, CreationFailed(u32), @@ -31,25 +31,97 @@ struct DynamicChannel { state: ChannelState, processor: Box, complete_data: CompleteData, + channel_id: u32, +} + +impl Drop for DynamicChannel { + fn drop(&mut self) { + if self.state == ChannelState::Opened { + self.processor.close(self.channel_id); + } + } +} + +struct DynamicChannelAllocator { + dynamic_channels: BTreeMap, + next_channel_id: u32, +} + +impl<'a> IntoIterator for &'a DynamicChannelAllocator { + type Item = (&'a u32, &'a DynamicChannel); + + type IntoIter = alloc::collections::btree_map::Iter<'a, u32, DynamicChannel>; + + fn into_iter(self) -> Self::IntoIter { + self.dynamic_channels.iter() + } +} + +impl<'a> IntoIterator for &'a mut DynamicChannelAllocator { + type Item = (&'a u32, &'a mut DynamicChannel); + type IntoIter = alloc::collections::btree_map::IterMut<'a, u32, DynamicChannel>; + fn into_iter(self) -> Self::IntoIter { + self.dynamic_channels.iter_mut() + } +} + +impl DynamicChannelAllocator { + fn new() -> Self { + Self { + dynamic_channels: BTreeMap::new(), + next_channel_id: 0, + } + } + + fn insert_channel(&mut self, processor: T, state: ChannelState) -> u32 + where + T: DvcServerProcessor + 'static, + { + let channel_id = self.next_channel_id; + self.dynamic_channels + .insert(channel_id, DynamicChannel::new(processor, channel_id, state)); + self.next_channel_id = self + .next_channel_id + .checked_add(1) + .expect("dynamic channels reaches `u32::MAX`"); + channel_id + } + + fn get(&self, channel_id: u32) -> Option<&DynamicChannel> { + self.dynamic_channels.get(&channel_id) + } + + fn get_mut(&mut self, channel_id: u32) -> Option<&mut DynamicChannel> { + self.dynamic_channels.get_mut(&channel_id) + } + + fn remove(&mut self, channel_id: u32) -> Option { + self.dynamic_channels.remove(&channel_id) + } } impl DynamicChannel { - fn new(processor: T) -> Self + fn new(processor: T, channel_id: u32, state: ChannelState) -> Self where T: DvcServerProcessor + 'static, { Self { - state: ChannelState::Closed, + state, processor: Box::new(processor), complete_data: CompleteData::new(), + channel_id, } } + + fn processor_type_id(&self) -> TypeId { + self.processor.as_any().type_id() + } } /// DRDYNVC Static Virtual Channel (the Remote Desktop Protocol: Dynamic Virtual Channel Extension) /// /// It adds support for dynamic virtual channels (DVC). pub struct DrdynvcServer { - dynamic_channels: Slab, + dynamic_channels: DynamicChannelAllocator, type_id_to_channel_id: BTreeMap, } @@ -57,7 +129,7 @@ impl fmt::Debug for DrdynvcServer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "DrdynvcServer([")?; - for (i, (id, channel)) in self.dynamic_channels.iter().enumerate() { + for (i, (id, channel)) in self.dynamic_channels.into_iter().enumerate() { if i > 0 { write!(f, ", ")?; } @@ -73,7 +145,7 @@ impl DrdynvcServer { pub fn new() -> Self { Self { - dynamic_channels: Slab::new(), + dynamic_channels: DynamicChannelAllocator::new(), type_id_to_channel_id: BTreeMap::new(), } } @@ -88,11 +160,8 @@ impl DrdynvcServer { /// Returns `true` if the DVC channel with the given ID has completed /// its creation handshake and is in the `Opened` state. pub fn is_channel_opened(&self, channel_id: u32) -> bool { - let Ok(id) = usize::try_from(channel_id) else { - return false; - }; self.dynamic_channels - .get(id) + .get(channel_id) .is_some_and(|c| c.state == ChannelState::Opened) } @@ -100,21 +169,18 @@ impl DrdynvcServer { /// /// # Panics /// - /// Panics if the number of registered dynamic channels exceeds `u32::MAX`. + /// Panics if the number of registered dynamic channels reaches `u32::MAX`. #[must_use] pub fn with_dynamic_channel(mut self, channel: T) -> Self where T: DvcServerProcessor + 'static, { - let id = self.dynamic_channels.insert(DynamicChannel::new(channel)); - // The slab index is used as the DVC channel ID (a u32). - let channel_id = u32::try_from(id).expect("DVC channel count should not exceed u32::MAX"); + let channel_id = self.dynamic_channels.insert_channel(channel, ChannelState::Pending); self.type_id_to_channel_id.insert(TypeId::of::(), channel_id); self } fn channel_by_id(&mut self, id: u32) -> DecodeResult<&mut DynamicChannel> { - let id = cast_length!("DRDYNVC", "", id)?; self.dynamic_channels .get_mut(id) .ok_or_else(|| invalid_field_err!("DRDYNVC", "", "invalid channel id")) @@ -124,22 +190,38 @@ impl DrdynvcServer { /// /// # Panics /// - /// Panics if the number of registered dynamic channels exceeds `u32::MAX`. + /// Panics if the number of registered dynamic channels reaches `u32::MAX`. pub fn create_channel(&mut self, channel: T) -> PduResult where T: DvcServerProcessor + 'static, { let channel_name = channel.channel_name().into(); - let mut dvc = DynamicChannel::new(channel); - dvc.state = ChannelState::Creation; - - let id = self.dynamic_channels.insert(dvc); - // The slab index is used as the DVC channel ID (a u32). - let channel_id = u32::try_from(id).expect("DVC channel count should not exceed u32::MAX"); + let channel_id = self.dynamic_channels.insert_channel(channel, ChannelState::Creation); let req = DrdynvcServerPdu::Create(CreateRequestPdu::new(channel_id, channel_name)); as_svc_msg_with_flag(req) } + + fn remove_by_channel_id(&mut self, id: u32) -> Option { + self.dynamic_channels.remove(id).inspect(|dvc| { + let type_id = dvc.processor_type_id(); + + // Only matters for pre-registered channels + if let alloc::collections::btree_map::Entry::Occupied(entry) = self.type_id_to_channel_id.entry(type_id) + && entry.get() == &id + { + entry.remove(); + } + }) + } + + pub fn close_channel(&mut self, channel_id: u32) -> Option { + self.remove_by_channel_id(channel_id)?; + Some( + SvcMessage::from(DrdynvcServerPdu::Close(ClosePdu::new(channel_id))) + .with_flags(ChannelFlags::SHOW_PROTOCOL), + ) + } } impl_as_any!(DrdynvcServer); @@ -173,15 +255,11 @@ impl SvcProcessor for DrdynvcServer { match pdu { DrdynvcClientPdu::Capabilities(caps_resp) => { debug!("Got DVC Capabilities Response PDU: {caps_resp:?}"); - for (id, c) in self.dynamic_channels.iter_mut() { - if c.state != ChannelState::Closed { + for (id, c) in &mut self.dynamic_channels { + if c.state != ChannelState::Pending { continue; } - let req = DrdynvcServerPdu::Create(CreateRequestPdu::new( - id.try_into() - .map_err(|e| pdu_other_err!("invalid channel id", source: e))?, - c.processor.channel_name().into(), - )); + let req = DrdynvcServerPdu::Create(CreateRequestPdu::new(*id, c.processor.channel_name().into())); c.state = ChannelState::Creation; resp.push(as_svc_msg_with_flag(req)?); } @@ -201,15 +279,10 @@ impl SvcProcessor for DrdynvcServer { let msg = c.processor.start(create_resp.channel_id())?; resp.extend(encode_dvc_messages(id, msg, ChannelFlags::SHOW_PROTOCOL).map_err(|e| encode_err!(e))?); } - DrdynvcClientPdu::Close(close_resp) => { - debug!("Got DVC Close Response PDU: {close_resp:?}"); - let c = self - .channel_by_id(close_resp.channel_id()) - .map_err(|e| decode_err!(e))?; - if c.state != ChannelState::Opened { - return Err(pdu_other_err!("invalid channel state")); - } - c.state = ChannelState::Closed; + DrdynvcClientPdu::Close(close) => { + debug!("Got DVC Close PDU: {close:?}"); + let channel_id = close.channel_id(); + self.remove_by_channel_id(channel_id); } DrdynvcClientPdu::Data(data) => { let channel_id = data.channel_id(); diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 8d6d678577..17b15d3aea 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -333,7 +333,6 @@ dependencies = [ "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", - "slab", "tracing", ] @@ -658,12 +657,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "spki" version = "0.7.3" From 1f39e3540daaf5279b6c83f4328bae445fe1ab3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Tue, 26 May 2026 23:38:42 +0900 Subject: [PATCH 250/325] chore(release): prepare web packages for publishing (#1312) * iron-remote-desktop v0.11.0 * iron-remote-desktop-rdp v0.7.0 --- .../iron-remote-desktop-rdp/.prettierignore | 3 + .../public/CHANGELOG.md | 67 +++++++++++++++++++ .../public/package.json | 2 +- .../iron-remote-desktop/.prettierignore | 3 + .../iron-remote-desktop/public/CHANGELOG.md | 36 ++++++++++ .../iron-remote-desktop/public/package.json | 2 +- 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 web-client/iron-remote-desktop-rdp/public/CHANGELOG.md create mode 100644 web-client/iron-remote-desktop/public/CHANGELOG.md diff --git a/web-client/iron-remote-desktop-rdp/.prettierignore b/web-client/iron-remote-desktop-rdp/.prettierignore index f4ae8393fd..ea8bb5f411 100644 --- a/web-client/iron-remote-desktop-rdp/.prettierignore +++ b/web-client/iron-remote-desktop-rdp/.prettierignore @@ -13,3 +13,6 @@ node_modules/ pnpm-lock.yaml package-lock.json yarn.lock + +# Auto-generated by git-cliff +/public/CHANGELOG.md diff --git a/web-client/iron-remote-desktop-rdp/public/CHANGELOG.md b/web-client/iron-remote-desktop-rdp/public/CHANGELOG.md new file mode 100644 index 0000000000..296468d3ae --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/public/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [0.7.0] - 2026-05-26 + +### Features + +- [**breaking**] Extend `DeviceEvent.wheelRotations` event to support passing rotation units other than pixels ([#952](https://github.com/Devolutions/IronRDP/issues/952)) ([23c0cc2c36](https://github.com/Devolutions/IronRDP/commit/23c0cc2c365159d24330a89ec4015121b67bccb6)) + +- Human-readable descriptions for RDCleanPath errors ([#999](https://github.com/Devolutions/IronRDP/issues/999)) ([18c81ed5d8](https://github.com/Devolutions/IronRDP/commit/18c81ed5d8d3bf13b3d10fe15209233c0c10bb62)) + + Web-side error strings for RDCleanPath general/negotiation + failures, including HTTP, WSA, and TLS error conditions. + +- Configurable `alternate_shell` and `work_dir` ([#1095](https://github.com/Devolutions/IronRDP/issues/1095)) ([a33d27fe67](https://github.com/Devolutions/IronRDP/commit/a33d27fe6771a5a155161ef40a04de88803dd84c)) + + Expose `ClientInfoPdu` `alternate_shell` and `work_dir` fields for + RemoteApp, custom shells, and PSM session tokens. + +- Negotiate bulk compression with the server ([ebf5da5f33](https://github.com/Devolutions/IronRDP/commit/ebf5da5f3380a3355f6c95814d669f8190425ded)) + + Advertise compression in Client Info and decode compressed + FastPath and ShareData updates (MPPC/NCRUSH/XCRUSH). + +- Decode multitransport request PDUs ([#1092](https://github.com/Devolutions/IronRDP/issues/1092), [#1096](https://github.com/Devolutions/IronRDP/issues/1096)) ([4f5fdd3628](https://github.com/Devolutions/IronRDP/commit/4f5fdd3628f4d0d2c2a4116e4e45269d802740f1)) + + Advertise the multitransport channel in GCC blocks and dispatch + `MultitransportRequestPdu` from the IO channel. The web client + logs the request; UDP transport is not yet wired up. + +- Expose granular RDCleanPath error details ([#1117](https://github.com/Devolutions/IronRDP/issues/1117)) ([2911124e8f](https://github.com/Devolutions/IronRDP/commit/2911124e8fe6160bc8ba03a574b67077e6d2cca9)) + + Forward HTTP status, WSA, and TLS alert codes from RDCleanPath + errors so the web client can distinguish specific network + failures. + +- Clipboard file transfer support ([#1064](https://github.com/Devolutions/IronRDP/issues/1064), [#1065](https://github.com/Devolutions/IronRDP/issues/1065), [#1066](https://github.com/Devolutions/IronRDP/issues/1066), [#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + End-to-end clipboard file transfer (upload and download) across + the CLIPRDR channel per MS-RDPECLIP. + +- Web RDPDR virtual printer support ([#1230](https://github.com/Devolutions/IronRDP/issues/1230)) ([14b1cef9cb](https://github.com/Devolutions/IronRDP/commit/14b1cef9cbbd0d8ef5e1fc8c73a3003a5e9f9bc2)) + + Announce a redirected printer over RDPDR, receive server print + jobs, and deliver completed PostScript jobs to a browser + callback. + +### Bug Fixes + +- Fix `this.lastSentClipboardData` being nulled ([#992](https://github.com/Devolutions/IronRDP/issues/992)) ([6127e13c83](https://github.com/Devolutions/IronRDP/commit/6127e13c836d06764d483b6b55188fd23a4314a2)) + +- Handle Auto-Detect Request PDUs from the server ([#1178](https://github.com/Devolutions/IronRDP/issues/1178)) ([4dcad09980](https://github.com/Devolutions/IronRDP/commit/4dcad09980e4f5354e4e435a134cc0956e2fcf9e)) + + Fix a session-terminating "unhandled PDU: Auto-Detect Request + PDU" error when servers send auto-detect requests during the + active phase. + +- Propagate negotiated `share_id` to all outgoing `ShareDataPdu` ([#1147](https://github.com/Devolutions/IronRDP/issues/1147)) ([2b24e9664d](https://github.com/Devolutions/IronRDP/commit/2b24e9664dd05620ff63a24d092377477fdde863)) + +### Build + +- Upgrade sspi and fix NTLM fallback ([#1188](https://github.com/Devolutions/IronRDP/issues/1188)) ([c70d38a9f1](https://github.com/Devolutions/IronRDP/commit/c70d38a9f190d6ad6c84bd9027a388b5db3296ba)) diff --git a/web-client/iron-remote-desktop-rdp/public/package.json b/web-client/iron-remote-desktop-rdp/public/package.json index 8fb1a386b5..a4e54dfbd8 100644 --- a/web-client/iron-remote-desktop-rdp/public/package.json +++ b/web-client/iron-remote-desktop-rdp/public/package.json @@ -6,7 +6,7 @@ "Benoit Cortier" ], "description": "RDP backend for iron-remote-desktop", - "version": "0.6.1", + "version": "0.7.0", "main": "iron-remote-desktop-rdp.js", "types": "index.d.ts", "files": [ diff --git a/web-client/iron-remote-desktop/.prettierignore b/web-client/iron-remote-desktop/.prettierignore index 82037396bb..da1310908e 100644 --- a/web-client/iron-remote-desktop/.prettierignore +++ b/web-client/iron-remote-desktop/.prettierignore @@ -15,3 +15,6 @@ node_modules/ pnpm-lock.yaml package-lock.json yarn.lock + +# Auto-generated by git-cliff +/public/CHANGELOG.md diff --git a/web-client/iron-remote-desktop/public/CHANGELOG.md b/web-client/iron-remote-desktop/public/CHANGELOG.md new file mode 100644 index 0000000000..19cc182192 --- /dev/null +++ b/web-client/iron-remote-desktop/public/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [0.11.0] - 2026-05-26 + +### Features + +- Expose granular RDCleanPath error details ([#1117](https://github.com/Devolutions/IronRDP/issues/1117)) ([2911124e8f](https://github.com/Devolutions/IronRDP/commit/2911124e8fe6160bc8ba03a574b67077e6d2cca9)) + + Surface HTTP status, WSA, and TLS alert codes from RDCleanPath + errors so consumers can distinguish specific network failures + (e.g. `WSAEACCES`/10013) instead of a generic message. + +- Clipboard file transfer API surface ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Backend-agnostic API for clipboard file upload and download, + consumed by backends that implement CLIPRDR file transfer. + +### Bug Fixes + +- Disable clipboard polling loop on Firefox v127+ ([#1162](https://github.com/Devolutions/IronRDP/issues/1162)) ([9a1ac3092e](https://github.com/Devolutions/IronRDP/commit/9a1ac3092ee3eac3e81823349d8e027065f5b8f8)) + +- Release mouse and keyboard state on focus loss to resolve Firefox stuck right-click ([#1297](https://github.com/Devolutions/IronRDP/issues/1297)) ([c56ea16d05](https://github.com/Devolutions/IronRDP/commit/c56ea16d05a88109815906b6d2501cfdae4c07c4)) + + `mouseOut()` and a new `focusLost()` handler release pressed + buttons and keys when the canvas loses focus (mouseleave, window + `blur`, document `visibilitychange`). `mouseIn()` reconciles + tracked server-side button state against `event.buttons` on + re-entry. + +- Include Meta keys in WebKit scancode dispatch ([#1304](https://github.com/Devolutions/IronRDP/issues/1304)) ([0bbffcd0ec](https://github.com/Devolutions/IronRDP/commit/0bbffcd0ec54eb9a14950db5f65f9a164dabc05d)) diff --git a/web-client/iron-remote-desktop/public/package.json b/web-client/iron-remote-desktop/public/package.json index d7e941ecb7..327881b78b 100644 --- a/web-client/iron-remote-desktop/public/package.json +++ b/web-client/iron-remote-desktop/public/package.json @@ -10,7 +10,7 @@ "Alexandr Yusuk" ], "description": "Backend-agnostic Web Component for remote desktop protocols", - "version": "0.10.1", + "version": "0.11.0", "main": "iron-remote-desktop.js", "types": "index.d.ts", "files": [ From 361bdc2fe87739b7ffb8a2eb1705d5014c9f209b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 16:15:38 +0000 Subject: [PATCH 251/325] feat: split ironrdp-client into a reusable library + ironrdp-viewer binary (#1309) --- ARCHITECTURE.md | 10 +- Cargo.lock | 45 +- README.md | 5 +- crates/ironrdp-client/Cargo.toml | 31 +- crates/ironrdp-client/README.md | 84 +-- crates/ironrdp-client/src/config.rs | 705 +----------------- crates/ironrdp-client/src/lib.rs | 4 - crates/ironrdp-client/src/rdp.rs | 59 +- crates/ironrdp-testsuite-extra/Cargo.toml | 1 + .../tests/config_rdp.rs | 9 +- crates/ironrdp-viewer/Cargo.toml | 68 ++ crates/ironrdp-viewer/README.md | 84 +++ .../src/app.rs | 6 +- .../src/clipboard.rs | 3 +- crates/ironrdp-viewer/src/config.rs | 689 +++++++++++++++++ crates/ironrdp-viewer/src/lib.rs | 14 + .../src/main.rs | 55 +- release-plz.toml | 2 +- 18 files changed, 1024 insertions(+), 850 deletions(-) create mode 100644 crates/ironrdp-viewer/Cargo.toml create mode 100644 crates/ironrdp-viewer/README.md rename crates/{ironrdp-client => ironrdp-viewer}/src/app.rs (99%) rename crates/{ironrdp-client => ironrdp-viewer}/src/clipboard.rs (94%) create mode 100644 crates/ironrdp-viewer/src/config.rs create mode 100644 crates/ironrdp-viewer/src/lib.rs rename crates/{ironrdp-client => ironrdp-viewer}/src/main.rs (69%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bcf00fcb7b..c5f27e34d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -168,7 +168,15 @@ NOTE: it’s not yet clear if this crate is an API Boundary or an implementation #### [`crates/ironrdp-client`](./crates/ironrdp-client) -Portable RDP client without GPU acceleration. +Reusable client engine library: holds the `Config`/`ConfigBuilder`, the `RdpClient` runtime, +input/output event types, and the WebSocket transport. Consumed by `ironrdp-viewer` and any +other embedder (e.g. a headless agent). + +#### [`crates/ironrdp-viewer`](./crates/ironrdp-viewer) + +Portable RDP client binary without GPU acceleration. A thin wrapper around `ironrdp-client` +that adds the winit/softbuffer GUI event loop, the clap CLI, the inquire prompts and the +`.rdp` file / PropertySet plumbing. #### [`crates/ironrdp-web`](./crates/ironrdp-web) diff --git a/Cargo.lock b/Cargo.lock index 12f3af5d65..411304e01f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2459,39 +2459,23 @@ name = "ironrdp-client" version = "0.1.0" dependencies = [ "anyhow", - "clap", "futures-util", - "inquire", "ironrdp", - "ironrdp-cfg", - "ironrdp-cliprdr-native", "ironrdp-core", "ironrdp-dvc-com-plugin", "ironrdp-dvc-pipe-proxy", "ironrdp-mstsgu", - "ironrdp-propertyset", "ironrdp-rdcleanpath", - "ironrdp-rdpfile", "ironrdp-rdpsnd-native", "ironrdp-tls", "ironrdp-tokio", - "proc-exit", - "raw-window-handle", - "semver", "smallvec", - "softbuffer", - "tap", "tokio", "tokio-tungstenite", "tokio-util", "tracing", - "tracing-subscriber", "transport", "url", - "uuid", - "whoami", - "windows", - "winit", "x509-cert", ] @@ -2924,6 +2908,7 @@ dependencies = [ "ironrdp-client", "ironrdp-tls", "ironrdp-tokio", + "ironrdp-viewer", "semver", "tokio", "tracing", @@ -2952,6 +2937,34 @@ dependencies = [ "url", ] +[[package]] +name = "ironrdp-viewer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "inquire", + "ironrdp", + "ironrdp-cfg", + "ironrdp-client", + "ironrdp-cliprdr-native", + "ironrdp-mstsgu", + "ironrdp-propertyset", + "ironrdp-rdpfile", + "proc-exit", + "raw-window-handle", + "semver", + "smallvec", + "softbuffer", + "tap", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "whoami", + "winit", +] + [[package]] name = "ironrdp-web" version = "0.0.0" diff --git a/README.md b/README.md index bbb1a51ec2..3bc5848515 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ Supported codecs: ## Examples -### [`ironrdp-client`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-client) +### [`ironrdp-viewer`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-viewer) A full-fledged RDP client based on IronRDP crates suite, and implemented using non-blocking, asynchronous I/O. +It is built on top of the reusable [`ironrdp-client`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-client) library crate. ```shell -cargo run --bin ironrdp-client -- --username --password +cargo run --bin ironrdp-viewer -- --username --password ``` ### [`screenshot`](https://github.com/Devolutions/IronRDP/blob/master/crates/ironrdp/examples/screenshot.rs) diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 946d3a07d6..4a393f5779 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -2,7 +2,7 @@ name = "ironrdp-client" version = "0.1.0" readme = "README.md" -description = "Portable RDP client without GPU acceleration" +description = "Portable RDP client engine without GPU acceleration" edition.workspace = true license.workspace = true homepage.workspace = true @@ -10,7 +10,6 @@ repository.workspace = true authors.workspace = true keywords.workspace = true categories.workspace = true -default-run = "ironrdp-client" # Not publishing for now. publish = false @@ -19,10 +18,6 @@ publish = false doctest = false test = false -[[bin]] -name = "ironrdp-client" -test = false - [features] default = ["rustls"] rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots", "ironrdp-mstsgu/rustls"] @@ -46,50 +41,30 @@ ironrdp = { path = "../ironrdp", version = "0.14", features = [ "echo", ] } ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.5" } ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.5" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.8", features = ["reqwest"] } ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" -ironrdp-propertyset.path = "../ironrdp-propertyset" -ironrdp-rdpfile.path = "../ironrdp-rdpfile" -ironrdp-cfg.path = "../ironrdp-cfg" - -# Windowing and rendering -winit = { version = "0.30", features = ["rwh_06"] } -softbuffer = "0.4" - -# CLI -clap = { version = "4.6", features = ["derive", "cargo"] } -proc-exit = "2" -inquire = "0.9" # Logging tracing = { version = "0.1", features = ["log"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Async, futures -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["macros", "net", "io-util", "sync", "rt", "time"] } tokio-util = { version = "0.7" } tokio-tungstenite = "0.29" transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" } futures-util = { version = "0.3", features = ["sink"] } # Utils -whoami = "2.1" anyhow = "1" smallvec = "1.15" -tap = "1" -semver = "1" -raw-window-handle = "0.6" -uuid = { version = ">=1.16, <1.21" } # Pinned below 1.21: see ironrdp-mstsgu/Cargo.toml for rationale. -x509-cert = { version = "0.2", default-features = false, features = ["std"] } url = "2" +x509-cert = { version = "0.2", default-features = false, features = ["std"] } [target.'cfg(windows)'.dependencies] -windows = { version = "0.62", features = ["Win32_Foundation"] } ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin" } [lints] diff --git a/crates/ironrdp-client/README.md b/crates/ironrdp-client/README.md index f909b3c5f3..595900155f 100644 --- a/crates/ironrdp-client/README.md +++ b/crates/ironrdp-client/README.md @@ -1,84 +1,18 @@ # IronRDP client -Portable RDP client without GPU acceleration. +Reusable RDP client engine library built on top of the IronRDP crates suite. -This is a a full-fledged RDP client based on IronRDP crates suite, and implemented using -non-blocking, asynchronous I/O. Portability is achieved by using softbuffer for rendering -and winit for windowing. +This crate is **library-only**: it exposes the `Config`, the `RdpClient` +runtime, input/output event types, the WebSocket transport, and the session driver. It is +consumed by `ironrdp-viewer` (the portable GUI client binary) and by any other embedder +(for example, a headless agent). -## Sample usage +The library is winit-agnostic. Output events are emitted on a bounded +`tokio::sync::mpsc::Sender` channel: the embedder is responsible +for consuming them and dispatching them to whatever event loop or runtime it wishes. -```shell -ironrdp-client --username --password -``` - -## `.rdp` file support - -You can load a `.rdp` file with `--rdp-file `. - -Currently supported properties: - -- `full address:s:` -- `alternate full address:s:` -- `server port:i:` -- `username:s:` -- `ClearTextPassword:s:` -- `domain:s:` -- `enablecredsspsupport:i:<0|1>` -- `gatewayhostname:s:` -- `gatewayusagemethod:i:` -- `gatewaycredentialssource:i:` -- `gatewayusername:s:` -- `GatewayPassword:s:` -- `kdcproxyurl:s:` (also `KDCProxyURL:s:`) -- `kdcproxyname:s:` -- `alternate shell:s:` -- `shell working directory:s:` -- `redirectclipboard:i:<0|1>` -- `audiomode:i:<0|1|2>` -- `desktopwidth:i:` -- `desktopheight:i:` -- `desktopscalefactor:i:` -- `compression:i:<0|1>` - -Property precedence is: - -1. CLI options -2. `.rdp` file values -3. Defaults and interactive prompts - -Unknown or unsupported `.rdp` properties are ignored and do not cause parsing failures. Parse -issues are reported to stderr. - - -The `IRONRDP_LOG` environment variable is used to set the log filter directives. - -```shell -IRONRDP_LOG="info,ironrdp_connector=trace" ironrdp-client --username --password -``` - -See [`tracing-subscriber`’s documentation][tracing-doc] for more details. - -[tracing-doc]: https://docs.rs/tracing-subscriber/0.3.17/tracing_subscriber/filter/struct.EnvFilter.html#directives - -## Support for `SSLKEYLOGFILE` - -This client supports reading the `SSLKEYLOGFILE` environment variable. -When set, the TLS encryption secrets for the session will be dumped to the file specified -by the environment variable. -This file can be read by Wireshark so that in can decrypt the packets. - -### Example - -```shell -SSLKEYLOGFILE=/tmp/tls-secrets ironrdp-client --username --password -``` - -### Usage in Wireshark - -See this [awakecoding's repository][awakecoding-repository] explaining how to use the file in wireshark. +For the end-user RDP client binary, see [`ironrdp-viewer`](../ironrdp-viewer). This crate is part of the [IronRDP] project. [IronRDP]: https://github.com/Devolutions/IronRDP -[awakecoding-repository]: https://github.com/awakecoding/wireshark-rdp#sslkeylogfile diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 4dcdbc20ae..4bcd595001 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -1,24 +1,19 @@ -#![allow(clippy::print_stdout, clippy::print_stderr)] - use core::fmt; -use core::num::ParseIntError; use core::str::FromStr; use core::time::Duration; +#[cfg(windows)] use std::path::PathBuf; use anyhow::Context as _; -use clap::Parser; -use clap::clap_derive::ValueEnum; -use ironrdp::connector::{self, Credentials}; -use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; -use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; +use ironrdp::connector; use ironrdp_mstsgu::GwConnectTarget; -use tap::prelude::*; use url::Url; -const DEFAULT_WIDTH: u16 = 1920; -const DEFAULT_HEIGHT: u16 = 1080; - +/// Fully resolved client configuration. +/// +/// This is the typed surface consumed by [`crate::rdp::RdpClient`]. Producing a `Config` +/// from CLI arguments, `.rdp` files, or interactive prompts is the consumer's responsibility +/// (see the `ironrdp-viewer` crate for a reference CLI front-end). #[derive(Clone, Debug)] pub struct Config { pub log_file: Option, @@ -45,119 +40,18 @@ pub struct Config { pub dvc_plugins: Vec, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +/// Resolved clipboard backend selection. +/// +/// Platform-specific details (e.g., which native clipboard backend to use) are handled +/// internally by the library when `Enable` is selected. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ClipboardType { - Default, + /// Enable clipboard redirection (use the best available backend). + Enable, + /// Disable clipboard redirection entirely. + Disable, + /// Use a stub clipboard backend (for testing or headless usage). Stub, - #[cfg(windows)] - Windows, - None, -} - -fn apply_cli_args_to_properties(properties: &mut ironrdp_propertyset::PropertySet, args: &Args) { - if let Some(dest) = &args.destination { - // Format the host in .rdp canonical form: IPv6 gets bracketed ("[::1]"), others are plain. - let host = dest - .name() - .parse::() - .map(ironrdp_cfg::TargetHost::Ip) - .unwrap_or_else(|_| ironrdp_cfg::TargetHost::Domain(dest.name().to_owned())); - properties.insert("full address", format!("{host}:{}", dest.port())); - } - - if let Some(username) = &args.username { - properties.insert("username", username.as_str()); - } - - if let Some(password) = &args.password { - properties.insert("ClearTextPassword", password.as_str()); - } - - if let Some(domain) = &args.domain { - properties.insert("domain", domain.as_str()); - } - - if let Some(scale) = args.scale_desktop { - properties.insert("desktopscalefactor", i64::from(scale)); - } - - if let Some(width) = args.desktop_width { - properties.insert("desktopwidth", i64::from(width)); - } - - if let Some(height) = args.desktop_height { - properties.insert("desktopheight", i64::from(height)); - } - - if let Some(gw_host) = &args.gw_endpoint { - properties.insert("gatewayhostname", gw_host.as_str()); - // Ensure the gateway is treated as enabled when a host is provided explicitly. - properties.insert( - "gatewayusagemethod", - ironrdp_cfg::GatewayUsageMethod::UseAlways.as_i64(), - ); - } - - if let Some(gw_user) = &args.gw_user { - properties.insert("gatewayusername", gw_user.as_str()); - } - - if let Some(gw_pass) = &args.gw_pass { - properties.insert("GatewayPassword", gw_pass.as_str()); - } - - if args.no_credssp { - properties.insert("enablecredsspsupport", 0i64); - } - - if let Some(enabled) = args.compression_enabled { - properties.insert("compression", enabled); - } -} - -fn compression_type_from_level(level: u32) -> anyhow::Result { - use ironrdp::pdu::rdp::client_info::CompressionType; - - match level { - 0 => Ok(CompressionType::K8), - 1 => Ok(CompressionType::K64), - 2 => Ok(CompressionType::Rdp6), - 3 => Ok(CompressionType::Rdp61), - _ => anyhow::bail!("Invalid compression level. Valid values are 0, 1, 2, 3."), - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -pub enum KeyboardType { - IbmPcXt, - OlivettiIco, - IbmPcAt, - IbmEnhanced, - Nokia1050, - Nokia9140, - Japanese, -} - -impl KeyboardType { - fn parse(keyboard_type: KeyboardType) -> ironrdp::pdu::gcc::KeyboardType { - match keyboard_type { - KeyboardType::IbmEnhanced => ironrdp::pdu::gcc::KeyboardType::IbmEnhanced, - KeyboardType::IbmPcAt => ironrdp::pdu::gcc::KeyboardType::IbmPcAt, - KeyboardType::IbmPcXt => ironrdp::pdu::gcc::KeyboardType::IbmPcXt, - KeyboardType::OlivettiIco => ironrdp::pdu::gcc::KeyboardType::OlivettiIco, - KeyboardType::Nokia1050 => ironrdp::pdu::gcc::KeyboardType::Nokia1050, - KeyboardType::Nokia9140 => ironrdp::pdu::gcc::KeyboardType::Nokia9140, - KeyboardType::Japanese => ironrdp::pdu::gcc::KeyboardType::Japanese, - } - } -} - -fn parse_hex(input: &str) -> Result { - if input.starts_with("0x") { - u32::from_str_radix(input.get(2..).unwrap_or(""), 16) - } else { - input.parse::() - } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -204,6 +98,17 @@ impl Destination { pub fn port(&self) -> u16 { self.port } + + /// Construct a `Destination` from already-validated components. + /// + /// Intended for front-ends that have already resolved the host and port from their own + /// configuration sources (CLI flags, `.rdp` files, IPC schemas). + pub fn from_parts(name: impl Into, port: u16) -> Self { + Self { + name: name.into(), + port, + } + } } impl fmt::Display for Destination { @@ -269,557 +174,3 @@ impl FromStr for DvcProxyInfo { }) } } - -/// Devolutions IronRDP client -#[derive(Parser, Debug)] -#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")] -#[clap(version, long_about = None)] -struct Args { - /// A file with IronRDP client logs - #[clap(short, long, value_parser)] - log_file: Option, - - #[clap(long, value_parser)] - gw_endpoint: Option, - #[clap(long, value_parser)] - gw_user: Option, - #[clap(long, value_parser)] - gw_pass: Option, - - /// An address on which the client will connect. - destination: Option, - - /// Path to a .rdp file to read the configuration from. - #[clap(long)] - rdp_file: Option, - - /// A target RDP server user name - #[clap(short, long)] - username: Option, - - /// An optional target RDP server domain name - #[clap(short, long)] - domain: Option, - - /// A target RDP server user password - #[clap(short, long)] - password: Option, - - /// Proxy URL to connect to for the RDCleanPath - #[clap(long, requires("rdcleanpath_token"))] - rdcleanpath_url: Option, - - /// Authentication token to insert in the RDCleanPath packet - #[clap(long, requires("rdcleanpath_url"))] - rdcleanpath_token: Option, - - /// The keyboard type - #[clap(long, value_enum, default_value_t = KeyboardType::IbmEnhanced)] - keyboard_type: KeyboardType, - - /// The keyboard subtype (an original equipment manufacturer-dependent value) - #[clap(long, default_value_t = 0)] - keyboard_subtype: u32, - - /// The number of function keys on the keyboard - #[clap(long, default_value_t = 12)] - keyboard_functional_keys_count: u32, - - /// The input method editor (IME) file name associated with the active input locale - #[clap(long, default_value_t = String::from(""))] - ime_file_name: String, - - /// Contains a value that uniquely identifies the client - #[clap(long, default_value_t = String::from(""))] - dig_product_id: String, - - /// Enable thin client - #[clap(long)] - thin_client: bool, - - /// Enable small cache - #[clap(long)] - small_cache: bool, - - /// Scaling factor for desktop applications, percentage (value between 100 and 500) - #[clap(long, value_parser = clap::value_parser!(u32).range(100..=500))] - scale_desktop: Option, - - /// Desired desktop width for the RDP session - #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] - desktop_width: Option, - - /// Desired desktop height for the RDP session - #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] - desktop_height: Option, - - /// Set required color depth. Currently only 32 and 16 bit color depths are supported - #[clap(long)] - color_depth: Option, - - /// Ignore mouse pointer messages sent by the server. Increases performance when enabled, as the - /// client could skip costly software rendering of the pointer with alpha blending - #[clap(long)] - no_server_pointer: bool, - - /// Enabled capability versions. Each bit represents enabling a capability version - /// starting from V8 to V10_7 - #[clap(long, value_parser = parse_hex, default_value_t = 0)] - capabilities: u32, - - /// Automatically logon to the server by passing the INFO_AUTOLOGON flag - /// - /// This flag is ignored if CredSSP authentication is used. - /// You can use `--no-credssp` to ensure it’s not. - #[clap(long)] - autologon: bool, - - /// Disable TLS + Graphical login (legacy authentication method) - /// - /// Disabling this in order to enforce usage of CredSSP (NLA) is recommended. - #[clap(long)] - no_tls: bool, - - /// Disable TLS + Network Level Authentication (NLA) using CredSSP - /// - /// NLA is used to authenticates RDP clients and servers before sending credentials over the network. - /// It’s not recommended to disable this. - #[clap(long, alias = "no-nla")] - no_credssp: bool, - - /// The clipboard type - #[clap(long, value_enum, default_value_t = ClipboardType::Default)] - clipboard_type: ClipboardType, - - /// The bitmap codecs to use (remotefx:on, ...) - #[clap(long, num_args = 1.., value_delimiter = ',')] - codecs: Vec, - - /// Enable bulk compression support (default: true). - /// - /// When enabled, the client advertises support for bulk compression and the - /// server may send compressed PDUs. Use `--compression-enabled=false` to - /// disable. When not specified, the value from the `.rdp` file is used (if - /// present), otherwise compression is enabled by default. - #[clap(long, action = clap::ArgAction::Set)] - compression_enabled: Option, - - /// Bulk compression level to negotiate with the server. - /// - /// Valid values: - /// 0 — MPPC with 8 KB history (RDP 4.0) - /// 1 — MPPC with 64 KB history (RDP 5.0) - /// 2 — NCRUSH (RDP 6.0) - /// 3 — XCRUSH (RDP 6.1) - #[clap(long, value_parser = clap::value_parser!(u32).range(0..=3), default_value_t = 3)] - compression_level: u32, - - /// Prevents session locking by injecting fake mouse movement events when - /// the connection is idle (interval in minutes) - #[clap(long)] - prevent_session_lock: Option, - - /// Add DVC channel named pipe proxy - /// - /// The format is `=`, e.g., `ChannelName=PipeName` where `ChannelName` is the name of the channel, - /// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix). - /// `` will automatically be prefixed with `\\.\pipe\` on Windows. - #[clap(long)] - dvc_proxy: Vec, - /// Load a DVC client plugin DLL (Windows only). - /// - /// Path to a DVC plugin DLL that exports VirtualChannelGetInstance. - /// Example: C:\Windows\System32\webauthn.dll - #[cfg(windows)] - #[clap(long)] - dvc_plugin: Vec, - - /// Write the effective PropertySet (merged .rdp file and CLI overrides) to the given path and exit. - /// - /// The output is a standard `.rdp` file that can be used as a starting point for customisation - /// or passed back via `--rdp-file` on the next invocation. - #[clap(long)] - dump_rdp: Option, -} - -/// The result of phase 1 parsing: the merged PropertySet plus CLI-only settings. -/// -/// After obtaining a `PartialConfig`, callers may inspect or serialise [`PartialConfig::properties`] -/// (e.g., with the `--dump-rdp` flag) before committing to a full session. Call -/// [`PartialConfig::into_config`] to complete phase 2 (interactive prompts + strong typing). -#[derive(Debug)] -pub struct PartialConfig { - /// The merged PropertySet (`.rdp` file + CLI overrides). - pub properties: ironrdp_propertyset::PropertySet, - - // CLI-only settings that are not representable as `.rdp` file properties. - pub log_file: Option, - pub dump_rdp: Option, - pub rdcleanpath: Option, - pub keyboard_type: KeyboardType, - pub keyboard_subtype: u32, - pub keyboard_functional_keys_count: u32, - pub ime_file_name: String, - pub dig_product_id: String, - pub thin_client: bool, - pub small_cache: bool, - pub color_depth: Option, - pub no_server_pointer: bool, - pub capabilities: u32, - pub autologon: bool, - pub no_tls: bool, - pub clipboard_type: ClipboardType, - pub codecs: Vec, - pub compression_level: u32, - pub prevent_session_lock: Option, - pub dvc_pipe_proxies: Vec, - #[cfg(windows)] - pub dvc_plugins: Vec, -} - -impl PartialConfig { - pub fn parse_args() -> anyhow::Result { - Self::parse_from(std::env::args_os()) - } - - pub fn parse_from(args: I) -> anyhow::Result - where - I: IntoIterator, - T: Into + Clone, - { - let args = Args::parse_from(args); - - let mut properties = ironrdp_propertyset::PropertySet::new(); - - if let Some(rdp_file) = &args.rdp_file { - let input = - std::fs::read_to_string(rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?; - - if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &input) { - for error in &errors { - eprintln!("Warning: skipped entry in {}: {error}", rdp_file.display()); - } - } - } - - // CLI arguments take precedence: upsert them after the .rdp file is loaded. - apply_cli_args_to_properties(&mut properties, &args); - - let rdcleanpath = args - .rdcleanpath_url - .zip(args.rdcleanpath_token) - .map(|(url, auth_token)| RDCleanPathConfig { url, auth_token }); - - Ok(Self { - properties, - log_file: args.log_file, - dump_rdp: args.dump_rdp, - rdcleanpath, - keyboard_type: args.keyboard_type, - keyboard_subtype: args.keyboard_subtype, - keyboard_functional_keys_count: args.keyboard_functional_keys_count, - ime_file_name: args.ime_file_name, - dig_product_id: args.dig_product_id, - thin_client: args.thin_client, - small_cache: args.small_cache, - color_depth: args.color_depth, - no_server_pointer: args.no_server_pointer, - capabilities: args.capabilities, - autologon: args.autologon, - no_tls: args.no_tls, - clipboard_type: args.clipboard_type, - codecs: args.codecs, - compression_level: args.compression_level, - prevent_session_lock: args.prevent_session_lock, - dvc_pipe_proxies: args.dvc_proxy, - #[cfg(windows)] - dvc_plugins: args.dvc_plugin, - }) - } - - pub fn into_config(self) -> anyhow::Result { - use ironrdp_cfg::{AudioMode, PropertySetExt as _}; - - let properties = &self.properties; - - let has_gateway_host = properties.gateway_hostname().is_some(); - let use_gateway = properties - .gateway_usage_method() - .unwrap_or_else(|e| { - eprintln!("Warning: {e}, assuming no gateway"); - Some(ironrdp_cfg::GatewayUsageMethod::Direct) - }) - .map_or(has_gateway_host, ironrdp_cfg::GatewayUsageMethod::is_gateway_required); - - let mut gw: Option = - use_gateway - .then(|| properties.gateway_hostname()) - .flatten() - .map(|gw_addr| GwConnectTarget { - gw_endpoint: gw_addr.to_owned(), - gw_user: String::new(), - gw_pass: String::new(), - server: String::new(), // TODO: non-standard port? also dont use here? - }); - - if let Some(ref mut gw) = gw { - if let Ok(Some(gateway_credentials_source)) = properties.gateway_credentials_source() { - // All known credential sources fall through to username/password prompts. - // The value is available for future differentiation if needed. - let _ = gateway_credentials_source; - } - - gw.gw_user = if let Some(gw_user) = properties.gateway_username() { - gw_user.to_owned() - } else { - inquire::Text::new("Gateway username:") - .prompt() - .context("Username prompt")? - }; - - gw.gw_pass = if let Some(gw_pass) = properties.gateway_password() { - gw_pass.to_owned() - } else { - inquire::Password::new("Gateway password:") - .without_confirmation() - .prompt() - .context("Password prompt")? - }; - }; - - let target = match properties.full_address().context("invalid 'full address' property")? { - Some(addr) => Some(addr), - None => properties - .alternate_full_address() - .context("invalid 'alternate full address' property")?, - }; - - let destination = if let Some(target) = target { - const RDP_DEFAULT_PORT: u16 = 3389; - let port = match target.port { - Some(p) => p, - None => properties - .server_port() - .context("invalid 'server port' property")? - .unwrap_or(RDP_DEFAULT_PORT), - }; - let name = match target.host { - ironrdp_cfg::TargetHost::Ip(ip) => ip.to_string(), - ironrdp_cfg::TargetHost::Domain(host) => host, - }; - Destination { name, port } - } else { - inquire::Text::new("Server address:") - .prompt() - .context("Address prompt")? - .pipe(Destination::new)? - }; - - if let Some(ref mut gw) = gw { - gw.server = destination.name.clone(); // TODO - } - - let username = if let Some(username) = properties.username() { - username.to_owned() - } else { - inquire::Text::new("Username:").prompt().context("Username prompt")? - }; - - let password = if let Some(password) = properties.clear_text_password() { - password.to_owned() - } else { - inquire::Password::new("Password:") - .without_confirmation() - .prompt() - .context("Password prompt")? - }; - - let codecs: Vec<_> = self.codecs.iter().map(|s| s.as_str()).collect(); - let codecs = match client_codecs_capabilities(&codecs) { - Ok(codecs) => codecs, - Err(help) => { - print!("{help}"); - std::process::exit(0); - } - }; - let mut bitmap = connector::BitmapConfig { - color_depth: 32, - lossy_compression: true, - codecs, - }; - - if let Some(color_depth) = self.color_depth { - if color_depth != 16 && color_depth != 32 { - anyhow::bail!("Invalid color depth. Only 16 and 32 bit color depths are supported."); - } - bitmap.color_depth = color_depth; - }; - - // make a duration from cmdline argument (minutes) - let fake_events_interval = self - .prevent_session_lock - .map(|v| Duration::from_secs(u64::from(v) * 60)); - - let enable_credssp = properties.enable_credssp_support().unwrap_or(true); - - let redirect_clipboard = properties.redirect_clipboard().unwrap_or(true); - let clipboard_type = if self.clipboard_type == ClipboardType::Default { - if !redirect_clipboard { - ClipboardType::None - } else { - #[cfg(windows)] - { - ClipboardType::Windows - } - #[cfg(not(windows))] - { - ClipboardType::None - } - } - } else { - self.clipboard_type - }; - - let enable_audio_playback = match properties.audio_mode() { - Ok(None) | Ok(Some(AudioMode::RedirectToClient)) => true, - Ok(Some(AudioMode::PlayOnServer | AudioMode::Disabled)) => false, - Err(e) => { - eprintln!("Warning: {e}, defaulting to audio playback enabled"); - true - } - }; - - let compression_enabled = properties.compression().unwrap_or(true); - - let compression_type = if compression_enabled { - Some(compression_type_from_level(self.compression_level)?) - } else { - None - }; - - let desktop_width = properties - .desktop_width() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopwidth' property"); - None - }) - .unwrap_or(DEFAULT_WIDTH); - let desktop_height = properties - .desktop_height() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopheight' property"); - None - }) - .unwrap_or(DEFAULT_HEIGHT); - let desktop_scale_factor = properties - .desktop_scale_factor() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopscalefactor' property"); - None - }) - .unwrap_or(0); - - let kdc_proxy_url = properties - .kdc_proxy_url() - .map(str::to_owned) - .or_else(|| properties.kdc_proxy_name().map(normalize_kdc_proxy_url_from_name)); - - let kerberos_config = kdc_proxy_url.and_then(|kdc_proxy_url| { - Url::parse(&kdc_proxy_url) - .ok() - .map(|url| connector::credssp::KerberosConfig { - kdc_proxy_url: Some(url), - // The hostname field is the client computer name used for Kerberos SPN negotiation. - hostname: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), - }) - .or_else(|| { - eprintln!("Warning: ignored invalid KDC proxy URL in 'kdcproxyname'/'KDCProxyURL' property"); - None - }) - }); - - let connector = connector::Config { - credentials: Credentials::UsernamePassword { username, password }, - domain: properties.domain().map(str::to_owned), - enable_tls: !self.no_tls, - enable_credssp, - keyboard_type: KeyboardType::parse(self.keyboard_type), - keyboard_subtype: self.keyboard_subtype, - keyboard_layout: 0, // the server SHOULD use the default active input locale identifier - keyboard_functional_keys_count: self.keyboard_functional_keys_count, - ime_file_name: self.ime_file_name, - dig_product_id: self.dig_product_id, - desktop_size: connector::DesktopSize { - width: desktop_width, - height: desktop_height, - }, - desktop_scale_factor, - bitmap: Some(bitmap), - client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch) - .pipe(u32::try_from) - .context("cargo package version")?, - client_name: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), - // NOTE: hardcode this value like in freerdp - // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 - client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), - platform: match whoami::platform() { - whoami::Platform::Windows => MajorPlatformType::WINDOWS, - whoami::Platform::Linux => MajorPlatformType::UNIX, - whoami::Platform::Mac => MajorPlatformType::MACINTOSH, - whoami::Platform::Ios => MajorPlatformType::IOS, - whoami::Platform::Android => MajorPlatformType::ANDROID, - _ => MajorPlatformType::UNSPECIFIED, - }, - hardware_id: None, - license_cache: None, - enable_server_pointer: !self.no_server_pointer, - autologon: self.autologon, - enable_audio_playback, - request_data: None, - pointer_software_rendering: false, - multitransport_flags: None, - compression_type, - performance_flags: PerformanceFlags::default(), - timezone_info: TimezoneInfo::default(), - alternate_shell: properties.alternate_shell().unwrap_or_default().to_owned(), - work_dir: properties.shell_working_directory().unwrap_or_default().to_owned(), - }; - - Ok(Config { - log_file: self.log_file, - gw, - kerberos_config, - destination, - connector, - clipboard_type, - rdcleanpath: self.rdcleanpath, - fake_events_interval, - dvc_pipe_proxies: self.dvc_pipe_proxies, - #[cfg(windows)] - dvc_plugins: self.dvc_plugins, - }) - } -} - -impl Config { - pub fn parse_args() -> anyhow::Result { - Self::parse_from(std::env::args_os()) - } - - pub fn parse_from(args: I) -> anyhow::Result - where - I: IntoIterator, - T: Into + Clone, - { - PartialConfig::parse_from(args)?.into_config() - } -} - -fn normalize_kdc_proxy_url_from_name(name: &str) -> String { - if name.starts_with("http://") || name.starts_with("https://") { - name.to_owned() - } else { - format!("https://{name}/KdcProxy") - } -} diff --git a/crates/ironrdp-client/src/lib.rs b/crates/ironrdp-client/src/lib.rs index de4b5276d9..753d2937a9 100644 --- a/crates/ironrdp-client/src/lib.rs +++ b/crates/ironrdp-client/src/lib.rs @@ -1,7 +1,5 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] -#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary - // No need to be as strict as in production libraries #![allow(clippy::arithmetic_side_effects)] #![allow(clippy::cast_lossless)] @@ -9,8 +7,6 @@ #![allow(clippy::cast_possible_wrap)] #![allow(clippy::cast_sign_loss)] -pub mod app; -pub mod clipboard; pub mod config; pub mod rdp; diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 9900f03312..073ddfe504 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -30,7 +30,6 @@ use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::mpsc; use tracing::{debug, error, info, trace, warn}; -use winit::event_loop::EventLoopProxy; use crate::config::{Config, RDCleanPathConfig}; @@ -107,7 +106,7 @@ pub type WriteDvcMessageFn = Box PduResult<()> + Send pub struct RdpClient { pub config: Config, - pub event_loop_proxy: EventLoopProxy, + pub output_event_sender: mpsc::Sender, pub input_event_receiver: mpsc::UnboundedReceiver, pub cliprdr_factory: Option>, pub dvc_pipe_proxy_factory: DvcPipeProxyFactory, @@ -127,7 +126,10 @@ impl RdpClient { { Ok(result) => result, Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e)); + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; break; } } @@ -141,7 +143,10 @@ impl RdpClient { { Ok(result) => result, Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e)); + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; break; } } @@ -150,7 +155,7 @@ impl RdpClient { match active_session( framed, connection_result, - &self.event_loop_proxy, + &self.output_event_sender, &mut self.input_event_receiver, ) .await @@ -160,11 +165,14 @@ impl RdpClient { self.config.connector.desktop_size.height = height; } Ok(RdpControlFlow::TerminatedGracefully(reason)) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::Terminated(Ok(reason))); + let _ = self + .output_event_sender + .send(RdpOutputEvent::Terminated(Ok(reason))) + .await; break; } Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::Terminated(Err(e))); + let _ = self.output_event_sender.send(RdpOutputEvent::Terminated(Err(e))).await; break; } } @@ -572,7 +580,7 @@ where async fn active_session( framed: UpgradedFramed, connection_result: ConnectionResult, - event_loop_proxy: &EventLoopProxy, + output_event_sender: &mpsc::Sender, input_event_receiver: &mut mpsc::UnboundedReceiver, ) -> SessionResult { let (mut reader, mut writer) = split_tokio_framed(framed); @@ -714,35 +722,40 @@ async fn active_session( }) .collect(); - event_loop_proxy - .send_event(RdpOutputEvent::Image { + output_event_sender + .send(RdpOutputEvent::Image { buffer, width: NonZeroU16::new(image.width()) .ok_or_else(|| session::general_err!("width is zero"))?, height: NonZeroU16::new(image.height()) .ok_or_else(|| session::general_err!("height is zero"))?, }) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerDefault => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerDefault) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerDefault) + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerHidden => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerHidden) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerHidden) + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerPosition { x, y } => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerPosition { x, y }) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerPosition { x, y }) + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerBitmap(pointer) => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerBitmap(pointer)) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerBitmap(pointer)) + .await + .map_err(|e| session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::DeactivateAll(mut connection_activation) => { // Execute the Deactivation-Reactivation Sequence: diff --git a/crates/ironrdp-testsuite-extra/Cargo.toml b/crates/ironrdp-testsuite-extra/Cargo.toml index 6d35757012..070d6f53a1 100644 --- a/crates/ironrdp-testsuite-extra/Cargo.toml +++ b/crates/ironrdp-testsuite-extra/Cargo.toml @@ -27,6 +27,7 @@ async-trait = "0.1" ironrdp = { path = "../ironrdp", features = ["server", "pdu", "connector", "session", "dvc", "echo"] } ironrdp-async.path = "../ironrdp-async" ironrdp-client.path = "../ironrdp-client" +ironrdp-viewer.path = "../ironrdp-viewer" ironrdp-tokio.path = "../ironrdp-tokio" ironrdp-tls = { path = "../ironrdp-tls", features = ["rustls"] } semver = "1.0" diff --git a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs index 2cb2e120eb..e60d12adef 100644 --- a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs +++ b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs @@ -1,7 +1,8 @@ use std::fs; use std::path::PathBuf; -use ironrdp_client::config::{ClipboardType, Config}; +use ironrdp_client::config::ClipboardType; +use ironrdp_viewer::config::parse_config_from; use uuid::Uuid; struct TempRdpFile { @@ -26,7 +27,7 @@ impl Drop for TempRdpFile { } } -fn parse_config_from_rdp(content: &str, extra_args: &[&str]) -> Config { +fn parse_config_from_rdp(content: &str, extra_args: &[&str]) -> ironrdp_client::config::Config { let rdp_file = TempRdpFile::new(content); let mut args = vec![ @@ -37,7 +38,7 @@ fn parse_config_from_rdp(content: &str, extra_args: &[&str]) -> Config { args.extend(extra_args.iter().map(|arg| (*arg).to_owned())); - Config::parse_from(args).expect("failed to parse client config") + parse_config_from(args).expect("failed to parse client config") } #[test] @@ -102,7 +103,7 @@ fn redirectclipboard_zero_disables_clipboard_for_default_mode() { &[], ); - assert!(matches!(config.clipboard_type, ClipboardType::None)); + assert!(matches!(config.clipboard_type, ClipboardType::Disable)); } #[test] diff --git a/crates/ironrdp-viewer/Cargo.toml b/crates/ironrdp-viewer/Cargo.toml new file mode 100644 index 0000000000..f98870c1be --- /dev/null +++ b/crates/ironrdp-viewer/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "ironrdp-viewer" +version = "0.1.0" +readme = "README.md" +description = "Portable RDP viewer (GUI binary) without GPU acceleration" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +default-run = "ironrdp-viewer" + +# Not publishing for now. +publish = false + +[lib] +doctest = false +test = false + +[[bin]] +name = "ironrdp-viewer" +test = false + +[features] +default = ["rustls"] +rustls = ["ironrdp-client/rustls"] +native-tls = ["ironrdp-client/native-tls"] +qoi = ["ironrdp-client/qoi"] +qoiz = ["ironrdp-client/qoiz"] + +[dependencies] +ironrdp = { path = "../ironrdp", version = "0.14", features = ["input", "pdu"] } +ironrdp-client = { path = "../ironrdp-client", version = "0.1", default-features = false } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.5" } +ironrdp-cfg = { path = "../ironrdp-cfg" } +ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } +ironrdp-propertyset = { path = "../ironrdp-propertyset" } +ironrdp-rdpfile = { path = "../ironrdp-rdpfile" } + +# Windowing and rendering +winit = { version = "0.30", features = ["rwh_06"] } +softbuffer = "0.4" + +# CLI +clap = { version = "4.6", features = ["derive", "cargo"] } +inquire = "0.9" +proc-exit = "2" + +# Logging +tracing = { version = "0.1", features = ["log"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Async, futures +tokio = { version = "1", features = ["full"] } + +# Utils +whoami = "2.1" +anyhow = "1" +smallvec = "1.15" +tap = "1" +semver = "1" +raw-window-handle = "0.6" +url = "2" + +[lints] +workspace = true diff --git a/crates/ironrdp-viewer/README.md b/crates/ironrdp-viewer/README.md new file mode 100644 index 0000000000..18710c2fbd --- /dev/null +++ b/crates/ironrdp-viewer/README.md @@ -0,0 +1,84 @@ +# IronRDP Viewer + +Portable RDP client without GPU acceleration. + +This is a a full-fledged RDP client based on IronRDP crates suite, and implemented using +non-blocking, asynchronous I/O. Portability is achieved by using softbuffer for rendering +and winit for windowing. + +## Sample usage + +```shell +ironrdp-viewer --username --password +``` + +## `.rdp` file support + +You can load a `.rdp` file with `--rdp-file `. + +Currently supported properties: + +- `full address:s:` +- `alternate full address:s:` +- `server port:i:` +- `username:s:` +- `ClearTextPassword:s:` +- `domain:s:` +- `enablecredsspsupport:i:<0|1>` +- `gatewayhostname:s:` +- `gatewayusagemethod:i:` +- `gatewaycredentialssource:i:` +- `gatewayusername:s:` +- `GatewayPassword:s:` +- `kdcproxyurl:s:` (also `KDCProxyURL:s:`) +- `kdcproxyname:s:` +- `alternate shell:s:` +- `shell working directory:s:` +- `redirectclipboard:i:<0|1>` +- `audiomode:i:<0|1|2>` +- `desktopwidth:i:` +- `desktopheight:i:` +- `desktopscalefactor:i:` +- `compression:i:<0|1>` + +Property precedence is: + +1. CLI options +2. `.rdp` file values +3. Defaults and interactive prompts + +Unknown or unsupported `.rdp` properties are ignored and do not cause parsing failures. Parse +issues are reported to stderr. + + +The `IRONRDP_LOG` environment variable is used to set the log filter directives. + +```shell +IRONRDP_LOG="info,ironrdp_connector=trace" ironrdp-viewer --username --password +``` + +See [`tracing-subscriber`'s documentation][tracing-doc] for more details. + +[tracing-doc]: https://docs.rs/tracing-subscriber/0.3.17/tracing_subscriber/filter/struct.EnvFilter.html#directives + +## Support for `SSLKEYLOGFILE` + +This client supports reading the `SSLKEYLOGFILE` environment variable. +When set, the TLS encryption secrets for the session will be dumped to the file specified +by the environment variable. +This file can be read by Wireshark so that in can decrypt the packets. + +### Example + +```shell +SSLKEYLOGFILE=/tmp/tls-secrets ironrdp-viewer --username --password +``` + +### Usage in Wireshark + +See this [awakecoding's repository][awakecoding-repository] explaining how to use the file in wireshark. + +This crate is part of the [IronRDP] project. + +[IronRDP]: https://github.com/Devolutions/IronRDP +[awakecoding-repository]: https://github.com/awakecoding/wireshark-rdp#sslkeylogfile diff --git a/crates/ironrdp-client/src/app.rs b/crates/ironrdp-viewer/src/app.rs similarity index 99% rename from crates/ironrdp-client/src/app.rs rename to crates/ironrdp-viewer/src/app.rs index 9c6037a9cb..952e79d097 100644 --- a/crates/ironrdp-client/src/app.rs +++ b/crates/ironrdp-viewer/src/app.rs @@ -6,8 +6,10 @@ use std::sync::Arc; use std::time::Instant; use anyhow::Context as _; +use ironrdp::pdu::input::MousePdu; use ironrdp::pdu::input::fast_path::FastPathInputEvent; -use ironrdp::pdu::input::{MousePdu, mouse::PointerFlags}; +use ironrdp::pdu::input::mouse::PointerFlags; +use ironrdp_client::rdp::{RdpInputEvent, RdpOutputEvent}; use raw_window_handle::{DisplayHandle, HasDisplayHandle as _}; use smallvec::SmallVec; use tokio::sync::mpsc; @@ -19,8 +21,6 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::platform::scancode::PhysicalKeyExtScancode as _; use winit::window::{CursorIcon, CustomCursor, Window, WindowAttributes}; -use crate::rdp::{RdpInputEvent, RdpOutputEvent}; - type WindowSurface = (Arc, softbuffer::Surface, Arc>); pub struct App { diff --git a/crates/ironrdp-client/src/clipboard.rs b/crates/ironrdp-viewer/src/clipboard.rs similarity index 94% rename from crates/ironrdp-client/src/clipboard.rs rename to crates/ironrdp-viewer/src/clipboard.rs index a58716a948..9b2855844a 100644 --- a/crates/ironrdp-client/src/clipboard.rs +++ b/crates/ironrdp-viewer/src/clipboard.rs @@ -1,9 +1,8 @@ use ironrdp::cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; +use ironrdp_client::rdp::RdpInputEvent; use tokio::sync::mpsc; use tracing::error; -use crate::rdp::RdpInputEvent; - /// Shim for sending and receiving CLIPRDR events as `RdpInputEvent` #[derive(Clone, Debug)] pub struct ClientClipboardMessageProxy { diff --git a/crates/ironrdp-viewer/src/config.rs b/crates/ironrdp-viewer/src/config.rs new file mode 100644 index 0000000000..009f3b35ec --- /dev/null +++ b/crates/ironrdp-viewer/src/config.rs @@ -0,0 +1,689 @@ +#![allow(clippy::print_stdout, clippy::print_stderr)] + +use core::num::ParseIntError; +use core::time::Duration; +use std::path::PathBuf; + +use anyhow::Context as _; +use clap::Parser; +use clap::clap_derive::ValueEnum; +use ironrdp::connector::{self, Credentials}; +use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; +use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; +use ironrdp_client::config::{ + ClipboardType as ResolvedClipboardType, Config, Destination, DvcProxyInfo, RDCleanPathConfig, +}; +use ironrdp_mstsgu::GwConnectTarget; +use tap::prelude::*; +use url::Url; + +const DEFAULT_WIDTH: u16 = 1920; +const DEFAULT_HEIGHT: u16 = 1080; + +/// CLI selection for the clipboard backend. +/// +/// Maps directly into the library's [`ResolvedClipboardType`] when the typed [`Config`] is built. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum ClipboardType { + /// Enable clipboard redirection (use the best available backend). + Enable, + /// Disable clipboard redirection entirely. + Disable, + /// Use a stub clipboard backend (for testing or headless usage). + Stub, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum KeyboardType { + IbmPcXt, + OlivettiIco, + IbmPcAt, + IbmEnhanced, + Nokia1050, + Nokia9140, + Japanese, +} + +impl KeyboardType { + fn into_pdu(self) -> ironrdp::pdu::gcc::KeyboardType { + match self { + KeyboardType::IbmEnhanced => ironrdp::pdu::gcc::KeyboardType::IbmEnhanced, + KeyboardType::IbmPcAt => ironrdp::pdu::gcc::KeyboardType::IbmPcAt, + KeyboardType::IbmPcXt => ironrdp::pdu::gcc::KeyboardType::IbmPcXt, + KeyboardType::OlivettiIco => ironrdp::pdu::gcc::KeyboardType::OlivettiIco, + KeyboardType::Nokia1050 => ironrdp::pdu::gcc::KeyboardType::Nokia1050, + KeyboardType::Nokia9140 => ironrdp::pdu::gcc::KeyboardType::Nokia9140, + KeyboardType::Japanese => ironrdp::pdu::gcc::KeyboardType::Japanese, + } + } +} + +fn apply_cli_args_to_properties(properties: &mut ironrdp_propertyset::PropertySet, args: &Args) { + if let Some(dest) = &args.destination { + // Format the host in .rdp canonical form: IPv6 gets bracketed ("[::1]"), others are plain. + let host = dest + .name() + .parse::() + .map(ironrdp_cfg::TargetHost::Ip) + .unwrap_or_else(|_| ironrdp_cfg::TargetHost::Domain(dest.name().to_owned())); + properties.insert("full address", format!("{host}:{}", dest.port())); + } + + if let Some(username) = &args.username { + properties.insert("username", username.as_str()); + } + + if let Some(password) = &args.password { + properties.insert("ClearTextPassword", password.as_str()); + } + + if let Some(domain) = &args.domain { + properties.insert("domain", domain.as_str()); + } + + if let Some(scale) = args.scale_desktop { + properties.insert("desktopscalefactor", i64::from(scale)); + } + + if let Some(width) = args.desktop_width { + properties.insert("desktopwidth", i64::from(width)); + } + + if let Some(height) = args.desktop_height { + properties.insert("desktopheight", i64::from(height)); + } + + if let Some(gw_host) = &args.gw_endpoint { + properties.insert("gatewayhostname", gw_host.as_str()); + // Ensure the gateway is treated as enabled when a host is provided explicitly. + properties.insert( + "gatewayusagemethod", + ironrdp_cfg::GatewayUsageMethod::UseAlways.as_i64(), + ); + } + + if let Some(gw_user) = &args.gw_user { + properties.insert("gatewayusername", gw_user.as_str()); + } + + if let Some(gw_pass) = &args.gw_pass { + properties.insert("GatewayPassword", gw_pass.as_str()); + } + + if args.no_credssp { + properties.insert("enablecredsspsupport", 0i64); + } + + if let Some(enabled) = args.compression_enabled { + properties.insert("compression", enabled); + } +} + +fn compression_type_from_level(level: u32) -> anyhow::Result { + use ironrdp::pdu::rdp::client_info::CompressionType; + + match level { + 0 => Ok(CompressionType::K8), + 1 => Ok(CompressionType::K64), + 2 => Ok(CompressionType::Rdp6), + 3 => Ok(CompressionType::Rdp61), + _ => anyhow::bail!("Invalid compression level. Valid values are 0, 1, 2, 3."), + } +} + +fn parse_hex(input: &str) -> Result { + if input.starts_with("0x") { + u32::from_str_radix(input.get(2..).unwrap_or(""), 16) + } else { + input.parse::() + } +} + +/// Devolutions IronRDP viewer +#[derive(Parser, Debug)] +#[clap(author = "Devolutions", about = "Devolutions-IronRDP viewer")] +#[clap(version, long_about = None)] +struct Args { + /// A file with IronRDP viewer logs + #[clap(short, long, value_parser)] + log_file: Option, + + #[clap(long, value_parser)] + gw_endpoint: Option, + #[clap(long, value_parser)] + gw_user: Option, + #[clap(long, value_parser)] + gw_pass: Option, + + /// An address on which the client will connect. + destination: Option, + + /// Path to a .rdp file to read the configuration from. + #[clap(long)] + rdp_file: Option, + + /// A target RDP server user name + #[clap(short, long)] + username: Option, + + /// An optional target RDP server domain name + #[clap(short, long)] + domain: Option, + + /// A target RDP server user password + #[clap(short, long)] + password: Option, + + /// Proxy URL to connect to for the RDCleanPath + #[clap(long, requires("rdcleanpath_token"))] + rdcleanpath_url: Option, + + /// Authentication token to insert in the RDCleanPath packet + #[clap(long, requires("rdcleanpath_url"))] + rdcleanpath_token: Option, + + /// The keyboard type + #[clap(long, value_enum, default_value_t = KeyboardType::IbmEnhanced)] + keyboard_type: KeyboardType, + + /// The keyboard subtype (an original equipment manufacturer-dependent value) + #[clap(long, default_value_t = 0)] + keyboard_subtype: u32, + + /// The number of function keys on the keyboard + #[clap(long, default_value_t = 12)] + keyboard_functional_keys_count: u32, + + /// The input method editor (IME) file name associated with the active input locale + #[clap(long, default_value_t = String::from(""))] + ime_file_name: String, + + /// Contains a value that uniquely identifies the client + #[clap(long, default_value_t = String::from(""))] + dig_product_id: String, + + /// Enable thin client + #[clap(long)] + thin_client: bool, + + /// Enable small cache + #[clap(long)] + small_cache: bool, + + /// Scaling factor for desktop applications, percentage (value between 100 and 500) + #[clap(long, value_parser = clap::value_parser!(u32).range(100..=500))] + scale_desktop: Option, + + /// Desired desktop width for the RDP session + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + desktop_width: Option, + + /// Desired desktop height for the RDP session + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + desktop_height: Option, + + /// Set required color depth. Currently only 32 and 16 bit color depths are supported + #[clap(long)] + color_depth: Option, + + /// Ignore mouse pointer messages sent by the server. Increases performance when enabled, as the + /// client could skip costly software rendering of the pointer with alpha blending + #[clap(long)] + no_server_pointer: bool, + + /// Enabled capability versions. Each bit represents enabling a capability version + /// starting from V8 to V10_7 + #[clap(long, value_parser = parse_hex, default_value_t = 0)] + capabilities: u32, + + /// Automatically logon to the server by passing the INFO_AUTOLOGON flag + /// + /// This flag is ignored if CredSSP authentication is used. + /// You can use `--no-credssp` to ensure it’s not. + #[clap(long)] + autologon: bool, + + /// Disable TLS + Graphical login (legacy authentication method) + /// + /// Disabling this in order to enforce usage of CredSSP (NLA) is recommended. + #[clap(long)] + no_tls: bool, + + /// Disable TLS + Network Level Authentication (NLA) using CredSSP + /// + /// NLA is used to authenticates RDP clients and servers before sending credentials over the network. + /// It’s not recommended to disable this. + #[clap(long, alias = "no-nla")] + no_credssp: bool, + + /// The clipboard type + #[clap(long, value_enum, default_value_t = ClipboardType::Enable)] + clipboard_type: ClipboardType, + + /// The bitmap codecs to use (remotefx:on, ...) + #[clap(long, num_args = 1.., value_delimiter = ',')] + codecs: Vec, + + /// Enable bulk compression support (default: true). + /// + /// When enabled, the client advertises support for bulk compression and the + /// server may send compressed PDUs. Use `--compression-enabled=false` to + /// disable. When not specified, the value from the `.rdp` file is used (if + /// present), otherwise compression is enabled by default. + #[clap(long, action = clap::ArgAction::Set)] + compression_enabled: Option, + + /// Bulk compression level to negotiate with the server. + /// + /// Valid values: + /// 0 — MPPC with 8 KB history (RDP 4.0) + /// 1 — MPPC with 64 KB history (RDP 5.0) + /// 2 — NCRUSH (RDP 6.0) + /// 3 — XCRUSH (RDP 6.1) + #[clap(long, value_parser = clap::value_parser!(u32).range(0..=3), default_value_t = 3)] + compression_level: u32, + + /// Prevents session locking by injecting fake mouse movement events when + /// the connection is idle (interval in minutes) + #[clap(long)] + prevent_session_lock: Option, + + /// Add DVC channel named pipe proxy + /// + /// The format is `=`, e.g., `ChannelName=PipeName` where `ChannelName` is the name of the channel, + /// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix). + /// `` will automatically be prefixed with `\\.\pipe\` on Windows. + #[clap(long)] + dvc_proxy: Vec, + /// Load a DVC client plugin DLL (Windows only). + /// + /// Path to a DVC plugin DLL that exports VirtualChannelGetInstance. + /// Example: C:\Windows\System32\webauthn.dll + #[cfg(windows)] + #[clap(long)] + dvc_plugin: Vec, + + /// Write the effective PropertySet (merged .rdp file and CLI overrides) to the given path and exit. + /// + /// The output is a standard `.rdp` file that can be used as a starting point for customisation + /// or passed back via `--rdp-file` on the next invocation. + #[clap(long)] + dump_rdp: Option, +} + +/// The result of phase 1 parsing: the merged PropertySet plus CLI-only settings. +/// +/// After obtaining a `PartialConfig`, callers may inspect or serialise [`PartialConfig::properties`] +/// (e.g., with the `--dump-rdp` flag) before committing to a full session. Call +/// [`PartialConfig::into_config`] to complete phase 2 (interactive prompts + strong typing). +#[derive(Debug)] +pub struct PartialConfig { + /// The merged PropertySet (`.rdp` file + CLI overrides). + pub properties: ironrdp_propertyset::PropertySet, + + // CLI-only settings that are not representable as `.rdp` file properties. + pub log_file: Option, + pub dump_rdp: Option, + pub rdcleanpath: Option, + pub keyboard_type: KeyboardType, + pub keyboard_subtype: u32, + pub keyboard_functional_keys_count: u32, + pub ime_file_name: String, + pub dig_product_id: String, + pub thin_client: bool, + pub small_cache: bool, + pub color_depth: Option, + pub no_server_pointer: bool, + pub capabilities: u32, + pub autologon: bool, + pub no_tls: bool, + pub clipboard_type: ClipboardType, + pub codecs: Vec, + pub compression_level: u32, + pub prevent_session_lock: Option, + pub dvc_pipe_proxies: Vec, + #[cfg(windows)] + pub dvc_plugins: Vec, +} + +impl PartialConfig { + pub fn parse_args() -> anyhow::Result { + Self::parse_from(std::env::args_os()) + } + + pub fn parse_from(args: I) -> anyhow::Result + where + I: IntoIterator, + T: Into + Clone, + { + let args = Args::parse_from(args); + + let mut properties = ironrdp_propertyset::PropertySet::new(); + + if let Some(rdp_file) = &args.rdp_file { + let input = + std::fs::read_to_string(rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?; + + if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &input) { + for error in &errors { + eprintln!("Warning: skipped entry in {}: {error}", rdp_file.display()); + } + } + } + + // CLI arguments take precedence: upsert them after the .rdp file is loaded. + apply_cli_args_to_properties(&mut properties, &args); + + let rdcleanpath = args + .rdcleanpath_url + .zip(args.rdcleanpath_token) + .map(|(url, auth_token)| RDCleanPathConfig { url, auth_token }); + + Ok(Self { + properties, + log_file: args.log_file, + dump_rdp: args.dump_rdp, + rdcleanpath, + keyboard_type: args.keyboard_type, + keyboard_subtype: args.keyboard_subtype, + keyboard_functional_keys_count: args.keyboard_functional_keys_count, + ime_file_name: args.ime_file_name, + dig_product_id: args.dig_product_id, + thin_client: args.thin_client, + small_cache: args.small_cache, + color_depth: args.color_depth, + no_server_pointer: args.no_server_pointer, + capabilities: args.capabilities, + autologon: args.autologon, + no_tls: args.no_tls, + clipboard_type: args.clipboard_type, + codecs: args.codecs, + compression_level: args.compression_level, + prevent_session_lock: args.prevent_session_lock, + dvc_pipe_proxies: args.dvc_proxy, + #[cfg(windows)] + dvc_plugins: args.dvc_plugin, + }) + } + + pub fn into_config(self) -> anyhow::Result { + use ironrdp_cfg::{AudioMode, PropertySetExt as _}; + + let properties = &self.properties; + + let has_gateway_host = properties.gateway_hostname().is_some(); + let use_gateway = properties + .gateway_usage_method() + .unwrap_or_else(|e| { + eprintln!("Warning: {e}, assuming no gateway"); + Some(ironrdp_cfg::GatewayUsageMethod::Direct) + }) + .map_or(has_gateway_host, ironrdp_cfg::GatewayUsageMethod::is_gateway_required); + + let mut gw: Option = + use_gateway + .then(|| properties.gateway_hostname()) + .flatten() + .map(|gw_addr| GwConnectTarget { + gw_endpoint: gw_addr.to_owned(), + gw_user: String::new(), + gw_pass: String::new(), + server: String::new(), // TODO: non-standard port? also dont use here? + }); + + if let Some(ref mut gw) = gw { + if let Ok(Some(gateway_credentials_source)) = properties.gateway_credentials_source() { + // All known credential sources fall through to username/password prompts. + // The value is available for future differentiation if needed. + let _ = gateway_credentials_source; + } + + gw.gw_user = if let Some(gw_user) = properties.gateway_username() { + gw_user.to_owned() + } else { + inquire::Text::new("Gateway username:") + .prompt() + .context("Username prompt")? + }; + + gw.gw_pass = if let Some(gw_pass) = properties.gateway_password() { + gw_pass.to_owned() + } else { + inquire::Password::new("Gateway password:") + .without_confirmation() + .prompt() + .context("Password prompt")? + }; + }; + + let target = match properties.full_address().context("invalid 'full address' property")? { + Some(addr) => Some(addr), + None => properties + .alternate_full_address() + .context("invalid 'alternate full address' property")?, + }; + + let destination = if let Some(target) = target { + const RDP_DEFAULT_PORT: u16 = 3389; + let port = match target.port { + Some(p) => p, + None => properties + .server_port() + .context("invalid 'server port' property")? + .unwrap_or(RDP_DEFAULT_PORT), + }; + let name = match target.host { + ironrdp_cfg::TargetHost::Ip(ip) => ip.to_string(), + ironrdp_cfg::TargetHost::Domain(host) => host, + }; + Destination::from_parts(name, port) + } else { + inquire::Text::new("Server address:") + .prompt() + .context("Address prompt")? + .pipe(Destination::new)? + }; + + if let Some(ref mut gw) = gw { + gw.server = destination.name().to_owned(); // TODO + } + + let username = if let Some(username) = properties.username() { + username.to_owned() + } else { + inquire::Text::new("Username:").prompt().context("Username prompt")? + }; + + let password = if let Some(password) = properties.clear_text_password() { + password.to_owned() + } else { + inquire::Password::new("Password:") + .without_confirmation() + .prompt() + .context("Password prompt")? + }; + + let codecs: Vec<_> = self.codecs.iter().map(|s| s.as_str()).collect(); + let codecs = match client_codecs_capabilities(&codecs) { + Ok(codecs) => codecs, + Err(help) => { + print!("{help}"); + std::process::exit(0); + } + }; + let mut bitmap = connector::BitmapConfig { + color_depth: 32, + lossy_compression: true, + codecs, + }; + + if let Some(color_depth) = self.color_depth { + if color_depth != 16 && color_depth != 32 { + anyhow::bail!("Invalid color depth. Only 16 and 32 bit color depths are supported."); + } + bitmap.color_depth = color_depth; + }; + + // make a duration from cmdline argument (minutes) + let fake_events_interval = self + .prevent_session_lock + .map(|v| Duration::from_secs(u64::from(v) * 60)); + + let enable_credssp = properties.enable_credssp_support().unwrap_or(true); + + let redirect_clipboard = properties.redirect_clipboard().unwrap_or(true); + let clipboard_type = resolve_clipboard_type(self.clipboard_type, redirect_clipboard); + + let enable_audio_playback = match properties.audio_mode() { + Ok(None) | Ok(Some(AudioMode::RedirectToClient)) => true, + Ok(Some(AudioMode::PlayOnServer | AudioMode::Disabled)) => false, + Err(e) => { + eprintln!("Warning: {e}, defaulting to audio playback enabled"); + true + } + }; + + let compression_enabled = properties.compression().unwrap_or(true); + + let compression_type = if compression_enabled { + Some(compression_type_from_level(self.compression_level)?) + } else { + None + }; + + let desktop_width = properties + .desktop_width() + .unwrap_or_else(|_| { + eprintln!("Warning: ignored out-of-range 'desktopwidth' property"); + None + }) + .unwrap_or(DEFAULT_WIDTH); + let desktop_height = properties + .desktop_height() + .unwrap_or_else(|_| { + eprintln!("Warning: ignored out-of-range 'desktopheight' property"); + None + }) + .unwrap_or(DEFAULT_HEIGHT); + let desktop_scale_factor = properties + .desktop_scale_factor() + .unwrap_or_else(|_| { + eprintln!("Warning: ignored out-of-range 'desktopscalefactor' property"); + None + }) + .unwrap_or(0); + + let kdc_proxy_url = properties + .kdc_proxy_url() + .map(str::to_owned) + .or_else(|| properties.kdc_proxy_name().map(normalize_kdc_proxy_url_from_name)); + + let kerberos_config = kdc_proxy_url.and_then(|kdc_proxy_url| { + Url::parse(&kdc_proxy_url) + .ok() + .map(|url| connector::credssp::KerberosConfig { + kdc_proxy_url: Some(url), + // The hostname field is the client computer name used for Kerberos SPN negotiation. + hostname: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), + }) + .or_else(|| { + eprintln!("Warning: ignored invalid KDC proxy URL in 'kdcproxyname'/'KDCProxyURL' property"); + None + }) + }); + + let connector = connector::Config { + credentials: Credentials::UsernamePassword { username, password }, + domain: properties.domain().map(str::to_owned), + enable_tls: !self.no_tls, + enable_credssp, + keyboard_type: self.keyboard_type.into_pdu(), + keyboard_subtype: self.keyboard_subtype, + keyboard_layout: 0, // the server SHOULD use the default active input locale identifier + keyboard_functional_keys_count: self.keyboard_functional_keys_count, + ime_file_name: self.ime_file_name, + dig_product_id: self.dig_product_id, + desktop_size: connector::DesktopSize { + width: desktop_width, + height: desktop_height, + }, + desktop_scale_factor, + bitmap: Some(bitmap), + client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) + .map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch) + .pipe(u32::try_from) + .context("cargo package version")?, + client_name: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), + // NOTE: hardcode this value like in freerdp + // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 + client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), + platform: match whoami::platform() { + whoami::Platform::Windows => MajorPlatformType::WINDOWS, + whoami::Platform::Linux => MajorPlatformType::UNIX, + whoami::Platform::Mac => MajorPlatformType::MACINTOSH, + whoami::Platform::Ios => MajorPlatformType::IOS, + whoami::Platform::Android => MajorPlatformType::ANDROID, + _ => MajorPlatformType::UNSPECIFIED, + }, + hardware_id: None, + license_cache: None, + enable_server_pointer: !self.no_server_pointer, + autologon: self.autologon, + enable_audio_playback, + request_data: None, + pointer_software_rendering: false, + multitransport_flags: None, + compression_type, + performance_flags: PerformanceFlags::default(), + timezone_info: TimezoneInfo::default(), + alternate_shell: properties.alternate_shell().unwrap_or_default().to_owned(), + work_dir: properties.shell_working_directory().unwrap_or_default().to_owned(), + }; + + Ok(Config { + log_file: self.log_file, + gw, + kerberos_config, + destination, + connector, + clipboard_type, + rdcleanpath: self.rdcleanpath, + fake_events_interval, + dvc_pipe_proxies: self.dvc_pipe_proxies, + #[cfg(windows)] + dvc_plugins: self.dvc_plugins, + }) + } +} + +fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> ResolvedClipboardType { + if !redirect_clipboard { + return ResolvedClipboardType::Disable; + } + + match cli { + ClipboardType::Enable => ResolvedClipboardType::Enable, + ClipboardType::Disable => ResolvedClipboardType::Disable, + ClipboardType::Stub => ResolvedClipboardType::Stub, + } +} + +pub fn parse_config() -> anyhow::Result { + PartialConfig::parse_args()?.into_config() +} + +pub fn parse_config_from(args: I) -> anyhow::Result +where + I: IntoIterator, + T: Into + Clone, +{ + PartialConfig::parse_from(args)?.into_config() +} + +fn normalize_kdc_proxy_url_from_name(name: &str) -> String { + if name.starts_with("http://") || name.starts_with("https://") { + name.to_owned() + } else { + format!("https://{name}/KdcProxy") + } +} diff --git a/crates/ironrdp-viewer/src/lib.rs b/crates/ironrdp-viewer/src/lib.rs new file mode 100644 index 0000000000..69402deb08 --- /dev/null +++ b/crates/ironrdp-viewer/src/lib.rs @@ -0,0 +1,14 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] +#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary + +// No need to be as strict as in production libraries +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::cast_lossless)] +#![allow(clippy::cast_possible_truncation)] +#![allow(clippy::cast_possible_wrap)] +#![allow(clippy::cast_sign_loss)] + +pub mod app; +pub mod clipboard; +pub mod config; diff --git a/crates/ironrdp-client/src/main.rs b/crates/ironrdp-viewer/src/main.rs similarity index 69% rename from crates/ironrdp-client/src/main.rs rename to crates/ironrdp-viewer/src/main.rs index 4e85b9cb1b..157ddb41f2 100644 --- a/crates/ironrdp-client/src/main.rs +++ b/crates/ironrdp-viewer/src/main.rs @@ -1,10 +1,12 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary use anyhow::Context as _; -use ironrdp_client::app::App; -use ironrdp_client::config::{ClipboardType, PartialConfig}; +use ironrdp_client::config::ClipboardType; use ironrdp_client::rdp::{DvcPipeProxyFactory, RdpClient, RdpInputEvent, RdpOutputEvent}; +use ironrdp_viewer::app::App; +use ironrdp_viewer::config::PartialConfig; use tokio::runtime; +use tokio::sync::mpsc; use tracing::debug; use winit::dpi::PhysicalSize; use winit::event_loop::EventLoop; @@ -26,6 +28,7 @@ fn main() -> anyhow::Result<()> { let event_loop = EventLoop::::with_user_event().build()?; let event_loop_proxy = event_loop.create_proxy(); let (input_event_sender, input_event_receiver) = RdpInputEvent::create_channel(); + let (output_event_sender, mut output_event_receiver) = mpsc::channel::(64); let initial_window_size = PhysicalSize::new( u32::from(config.connector.desktop_size.width), u32::from(config.connector.desktop_size.height), @@ -56,30 +59,54 @@ fn main() -> anyhow::Result<()> { let factory = cliprdr.backend_factory(); Some(factory) } - #[cfg(windows)] - ClipboardType::Windows => { - use ironrdp_client::clipboard::ClientClipboardMessageProxy; - use ironrdp_cliprdr_native::WinClipboard; - - let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?; - - let factory = cliprdr.backend_factory(); - _win_clipboard = cliprdr; - Some(factory) + ClipboardType::Enable => { + #[cfg(windows)] + { + use ironrdp_cliprdr_native::WinClipboard; + use ironrdp_viewer::clipboard::ClientClipboardMessageProxy; + + let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?; + + let factory = cliprdr.backend_factory(); + _win_clipboard = cliprdr; + Some(factory) + } + #[cfg(not(windows))] + { + // No native clipboard backend available on this platform; fall back to stub. + use ironrdp_cliprdr_native::StubClipboard; + + let cliprdr = StubClipboard::new(); + let factory = cliprdr.backend_factory(); + Some(factory) + } } - _ => None, + ClipboardType::Disable => None, }; let dvc_pipe_proxy_factory = DvcPipeProxyFactory::new(input_event_sender); let client = RdpClient { config, - event_loop_proxy, + output_event_sender, input_event_receiver, cliprdr_factory, dvc_pipe_proxy_factory, }; + // Forward output events from the library's mpsc channel to winit's `EventLoopProxy`. + // + // The library is winit-agnostic: it just emits `RdpOutputEvent`s on a plain + // `tokio::sync::mpsc` channel. Bridging onto the GUI event loop is the binary's job. + rt.spawn(async move { + while let Some(event) = output_event_receiver.recv().await { + if event_loop_proxy.send_event(event).is_err() { + // The event loop is gone; nothing left to forward. + break; + } + } + }); + debug!("Start RDP thread"); std::thread::spawn(move || { rt.block_on(client.run()); diff --git a/release-plz.toml b/release-plz.toml index 8a33bf6f9a..c5bbd12b09 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -9,7 +9,7 @@ release_commits = "^(feat|docs|fix|build|perf)" # Flagship crate for which we push a GitHub release. [[package]] -name = "ironrdp-client" +name = "ironrdp-viewer" git_release_enable = true publish = false # TODO: enable publishing when ready. From f9abc9eed73a32f8c2fc4bbed73880195da31544 Mon Sep 17 00:00:00 2001 From: Dion Gionet Mallet Date: Wed, 27 May 2026 04:04:38 -0400 Subject: [PATCH 252/325] ci: setup Node.js 24 (#1318) Should fix issues related to trusted publishing --- .github/workflows/npm-publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index d04a504d64..c8c5b2e521 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -110,6 +110,12 @@ jobs: with: fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - name: Download NPM packages artifact uses: actions/download-artifact@v8 with: From a71567e35e47a6eba8493c00933e0b66e0c63d5b Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 27 May 2026 03:22:11 -0500 Subject: [PATCH 253/325] fix(pdu): cover BitmapCacheV3 in CapabilitySet encoder (#1313) `CapabilitySet::BitmapCacheV3(Vec)` is in the enum, is decoded by the `BitmapCacheV3CodecID` arm, and is covered in `size()`, but was missing from both the outer and inner `match` of the `Encode` impl, so re-encoding a decoded `BitmapCacheV3` capability set hit the catch-all `_ => unreachable!()` and panicked. --- crates/ironrdp-fuzzing/src/oracles/mod.rs | 12 +++++------ .../src/rdp/capability_sets/mod.rs | 1 + ...-issue-1292-bitmapcache-v3-unreachable.bin | Bin 0 -> 4 bytes .../ironrdp-testsuite-core/tests/pdu/rdp.rs | 19 ++++++++++++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/crash-issue-1292-bitmapcache-v3-unreachable.bin diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index 19f16f85b9..98654cb923 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -243,6 +243,8 @@ macro_rules! pdu_round_trip_one { pub fn pdu_round_trip(data: &[u8]) { use ironrdp_pdu::mcs::{ConnectInitial, ConnectResponse, McsMessage}; use ironrdp_pdu::nego::{ConnectionConfirm, ConnectionRequest}; + use ironrdp_pdu::rdp::capability_sets::CapabilitySet; + use ironrdp_pdu::rdp::headers::ShareControlHeader; use ironrdp_pdu::rdp::{ClientInfoPdu, server_error_info, server_license, vc}; use ironrdp_pdu::x224::X224; use ironrdp_pdu::{bitmap, codecs, fast_path, gcc, input, pcb, surface_commands}; @@ -254,15 +256,13 @@ pub fn pdu_round_trip(data: &[u8]) { pdu_round_trip_one!(data, ConnectInitial); pdu_round_trip_one!(data, ConnectResponse); pdu_round_trip_one!(data, ClientInfoPdu); - // `capability_sets::CapabilitySet` AND `headers::ShareControlHeader` both - // transit through `CapabilitySet`'s encoder, which reaches `unreachable!()` - // (crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs:447) on variants the - // decoder accepts but the encoder match doesn't cover. Internal-panic bugs; - // can't be silently dropped at the oracle layer. Smoke-fuzz reproducer: - // `[6, 0, 4, 0]`. To be filed as a follow-up. pdu_round_trip_one!(data, pcb::PreconnectionBlob); pdu_round_trip_one!(data, server_error_info::ServerSetErrorInfoPdu); + // Capability sharing + pdu_round_trip_one!(data, CapabilitySet); + pdu_round_trip_one!(data, ShareControlHeader); + // GCC blocks and conference creation pdu_round_trip_one!(data, gcc::ClientGccBlocks); pdu_round_trip_one!(data, gcc::ServerGccBlocks); diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs index f94c695696..555888525a 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs @@ -444,6 +444,7 @@ impl Encode for CapabilitySet { CapabilitySet::DrawGdiPlus(buffer) => (CapabilitySetType::DrawGdiPlus, buffer), CapabilitySet::Rail(buffer) => (CapabilitySetType::Rail, buffer), CapabilitySet::WindowList(buffer) => (CapabilitySetType::WindowList, buffer), + CapabilitySet::BitmapCacheV3(buffer) => (CapabilitySetType::BitmapCacheV3CodecID, buffer), _ => unreachable!(), }; diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/crash-issue-1292-bitmapcache-v3-unreachable.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/crash-issue-1292-bitmapcache-v3-unreachable.bin new file mode 100644 index 0000000000000000000000000000000000000000..57fee863538dac20df12e3f892d9cc0698cf9f43 GIT binary patch literal 4 LcmZQ$U||3N03-kl literal 0 HcmV?d00001 diff --git a/crates/ironrdp-testsuite-core/tests/pdu/rdp.rs b/crates/ironrdp-testsuite-core/tests/pdu/rdp.rs index d597a67943..e255765d81 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/rdp.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/rdp.rs @@ -447,3 +447,22 @@ fn buffer_length_is_correct_for_client_demand_active() { assert_eq!(expected_buffer_len, len); } + +/// Regression for issue #1292: decoding a `BitmapCacheV3` capability set then re-encoding it +/// must not reach `unreachable!()` in the `Encode` impl. The decoder accepts +/// `CapabilitySetType::BitmapCacheV3CodecID` (0x06) and stores the body in +/// `CapabilitySet::BitmapCacheV3(Vec)`; before the fix the inner `match` of the encoder's +/// catch-all arm did not cover that variant. +#[test] +fn bitmap_cache_v3_round_trip_does_not_panic() { + use ironrdp_pdu::rdp::capability_sets::CapabilitySet; + + // 4-byte capability-set header with type=BitmapCacheV3CodecID(0x06) and length=4 (header only) + let input: [u8; 4] = [0x06, 0x00, 0x04, 0x00]; + + let decoded: CapabilitySet = decode(&input).expect("decode BitmapCacheV3 capability set"); + assert!(matches!(decoded, CapabilitySet::BitmapCacheV3(_))); + + let encoded = encode_vec(&decoded).expect("re-encode must not panic"); + assert_eq!(encoded, input, "round-trip must reproduce the original bytes"); +} From 5d10391485b2a580860875beef2bc25b86dd5f45 Mon Sep 17 00:00:00 2001 From: clintcan Date: Wed, 27 May 2026 17:25:21 +0800 Subject: [PATCH 254/325] test(cliprdr): cover Preferred DropEffect advertise + inline DROPEFFECT_COPY response (#1308) Co-authored-by: Clint Christopher Canada --- .../tests/clipboard/mod.rs | 1 + .../tests/clipboard/preferred_drop_effect.rs | 138 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 crates/ironrdp-testsuite-core/tests/clipboard/preferred_drop_effect.rs diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs b/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs index 1a29600114..40c860a444 100644 --- a/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs @@ -9,6 +9,7 @@ mod lock_lifecycle; mod lock_strategy; mod lock_timeout; mod path_sanitization; +mod preferred_drop_effect; mod server_role; mod test_helpers; mod upload_and_cleanup; diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/preferred_drop_effect.rs b/crates/ironrdp-testsuite-core/tests/clipboard/preferred_drop_effect.rs new file mode 100644 index 0000000000..5c80976b74 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/preferred_drop_effect.rs @@ -0,0 +1,138 @@ +//! Tests for the `Preferred DropEffect` companion format that +//! [`Cliprdr::initiate_file_copy`] advertises alongside +//! `FileGroupDescriptorW`: +//! +//! 1. `initiate_file_copy` advertises BOTH `FileGroupDescriptorW` and +//! `Preferred DropEffect` in the outgoing `FormatList`. +//! 2. A subsequent `FormatDataRequest` for the drop-effect format id is +//! answered inline with the 4-byte little-endian `DROPEFFECT_COPY` +//! payload (`0x01 0x00 0x00 0x00`), not forwarded to the backend. + +use ironrdp_cliprdr::pdu::{ClipboardFormatName, ClipboardPdu, FileDescriptor, FormatDataRequest}; +use ironrdp_svc::{SvcMessage, SvcProcessor as _}; + +use super::test_helpers::init_ready_client; + +/// Decode an SvcMessage back into a ClipboardPdu for assertion. +/// Two `let` bindings are required so the byte buffer outlives the +/// borrowing PDU. +macro_rules! decode_pdu { + ($msg:expr => $bytes:ident, $pdu:ident) => { + let $bytes = ($msg).encode_unframed_pdu().unwrap(); + let $pdu = ironrdp_core::decode::>(&$bytes).unwrap(); + }; +} + +/// `initiate_file_copy` must advertise BOTH `FileGroupDescriptorW` +/// (the file list itself) AND `Preferred DropEffect` (the companion +/// format Windows Explorer pairs with file lists to engage its shell +/// file-copy machinery + native progress dialog). +#[test] +fn initiate_file_copy_advertises_drop_effect_alongside_file_group_descriptor() { + let mut cliprdr = init_ready_client(); + + let files = vec![ + FileDescriptor::new("alpha.txt").with_file_size(100), + FileDescriptor::new("beta.bin").with_file_size(200), + ]; + let messages: Vec = cliprdr.initiate_file_copy(files).unwrap().into(); + + assert_eq!( + messages.len(), + 1, + "initiate_file_copy should send a single FormatList PDU" + ); + + decode_pdu!(&messages[0] => bytes, pdu); + let ClipboardPdu::FormatList(format_list) = pdu else { + panic!("expected FormatList PDU, got {pdu:?}"); + }; + + let formats = format_list + .get_formats(true) + .expect("FormatList should decode under long-format-names"); + + let has_file_group_descriptor = formats + .iter() + .any(|f| f.name.as_ref().is_some_and(|n| n == &ClipboardFormatName::FILE_LIST)); + let has_drop_effect = formats.iter().any(|f| { + f.name + .as_ref() + .is_some_and(|n| n == &ClipboardFormatName::PREFERRED_DROP_EFFECT) + }); + + assert!( + has_file_group_descriptor, + "FormatList must advertise FileGroupDescriptorW; got {formats:#?}" + ); + assert!( + has_drop_effect, + "FormatList must advertise Preferred DropEffect; got {formats:#?}" + ); +} + +/// A `FormatDataRequest` for the drop-effect format id is answered +/// inline by `Cliprdr` itself (not forwarded to the backend) with the +/// 4-byte little-endian `DROPEFFECT_COPY = 0x00000001` payload. +/// +/// Keys off the format *name* (`PREFERRED_DROP_EFFECT`) when looking up +/// the id — wire-faithful (the remote keys off the name too), and +/// resilient to any internal-id constant changes upstream. +/// +/// If `local_drop_effect_format_id` ever stops being set by +/// `initiate_file_copy` (or the inline short-circuit in +/// `handle_format_data_request` is removed), this test fails because +/// `TestBackend::on_format_data_request` is a no-op — the request +/// would fall through to the backend, no response would be emitted, +/// and `responses.len()` would be `0`. +#[test] +fn format_data_request_for_drop_effect_returns_dropeffect_copy_inline() { + let mut cliprdr = init_ready_client(); + + // Drive `initiate_file_copy`; the returned FormatList carries the + // drop-effect format we need to query. + let files = vec![FileDescriptor::new("doc.txt").with_file_size(42)]; + let initiate_msgs: Vec = cliprdr.initiate_file_copy(files).unwrap().into(); + + decode_pdu!(&initiate_msgs[0] => initiate_bytes, initiate_pdu); + let ClipboardPdu::FormatList(format_list) = initiate_pdu else { + panic!("expected FormatList, got {initiate_pdu:?}"); + }; + let drop_effect_id = format_list + .get_formats(true) + .unwrap() + .into_iter() + .find(|f| { + f.name + .as_ref() + .is_some_and(|n| n == &ClipboardFormatName::PREFERRED_DROP_EFFECT) + }) + .expect("initiate_file_copy must advertise Preferred DropEffect") + .id; + + // Simulate the remote asking for the drop-effect format. + let request_pdu = ClipboardPdu::FormatDataRequest(FormatDataRequest { format: drop_effect_id }); + let request_bytes = ironrdp_core::encode_vec(&request_pdu).unwrap(); + let responses: Vec = cliprdr.process(&request_bytes).unwrap(); + + assert_eq!( + responses.len(), + 1, + "drop-effect FormatDataRequest must be answered inline with one FormatDataResponse" + ); + + decode_pdu!(&responses[0] => resp_bytes, resp_pdu); + let ClipboardPdu::FormatDataResponse(response) = resp_pdu else { + panic!("expected FormatDataResponse, got {resp_pdu:?}"); + }; + assert!(!response.is_error(), "response must not be an error"); + + // [MS-RDPECLIP] Preferred DropEffect payload is a 4-byte u32 LE. + // `DROPEFFECT_COPY = 0x00000001` is what `initiate_file_copy` + // semantically always means. + assert_eq!( + response.data(), + &[0x01, 0x00, 0x00, 0x00], + "Preferred DropEffect payload must be exactly 4 bytes DROPEFFECT_COPY (LE)" + ); +} From d5b3fa7db8a4ce74ac9a9aaff3064faf6cb6c920 Mon Sep 17 00:00:00 2001 From: Norbert Schultz Date: Wed, 27 May 2026 11:35:31 +0200 Subject: [PATCH 255/325] build(deps): bump sspi 0.21 / picky rc.23 for released RustCrypto (#1296) --- Cargo.lock | 755 ++++++++++++++++++---------- crates/ironrdp-connector/Cargo.toml | 4 +- crates/ironrdp/Cargo.toml | 2 +- ffi/Cargo.toml | 2 +- 4 files changed, 495 insertions(+), 268 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 411304e01f..9794ca196c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,17 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "addchain" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e33f6a175ec6a9e0aca777567f9ff7c3deefc255660df887e7fa3585e9801d8" +dependencies = [ + "num-bigint 0.3.3", + "num-integer", + "num-traits", +] + [[package]] name = "adler2" version = "2.0.1" @@ -26,30 +37,30 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" -version = "0.6.0-rc.5" +version = "0.6.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a578e7d4edaef88aeb9cdd81556f4a62266ce26601317c006a79e8bc58b5af" +checksum = "6b657e772794c6b04730ea897b66a058ccd866c16d1967da05eeeecec39043fe" dependencies = [ - "crypto-common 0.2.0-rc.8", + "crypto-common 0.2.2", "inout", ] [[package]] name = "aes" -version = "0.9.0-rc.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9e1c818b25efb32214df89b0ec22f01aa397aaeb718d1022bf0635a3bfd1a8" +checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" dependencies = [ - "cfg-if", "cipher", - "cpufeatures", + "cpubits", + "cpufeatures 0.3.0", ] [[package]] name = "aes-gcm" -version = "0.11.0-rc.2" +version = "0.11.0-rc.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f5c07f414d7dc0755870f84c7900425360288d24e0eae4836f9dee19a30fa5f" +checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972" dependencies = [ "aead", "aes", @@ -61,9 +72,9 @@ dependencies = [ [[package]] name = "aes-kw" -version = "0.3.0-rc.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02eaa2d54d0fad0116e4b1efb65803ea0bf059ce970a67cd49718d87e807cb51" +checksum = "40e4645e6ea320665abf87e13821f9a37ab204b34bcb18e34e7d1dcf2366516e" dependencies = [ "aes", "const-oid 0.10.2", @@ -274,7 +285,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -286,7 +297,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -316,7 +327,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -327,7 +338,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -462,15 +473,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" -dependencies = [ - "hybrid-array", -] - [[package]] name = "block-buffer" version = "0.12.0" @@ -539,7 +541,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -600,9 +602,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cbc" -version = "0.2.0-rc.1" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dbf9e5b071e9de872e32b73f485e8f644ff47c7011d95476733e7482ee3e5c3" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ "cipher", ] @@ -639,13 +641,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0-rc.6" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f895fb33c1ad22da4bc79d37c0bddff8aee2ba4575705345eb73b8ffbc386074" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core 0.10.0-rc-3", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -690,12 +692,12 @@ dependencies = [ [[package]] name = "cipher" -version = "0.5.0-rc.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98d708bac5451350d56398433b19a7889022fa9187df1a769c0edbc3b2c03167" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.11.0", - "crypto-common 0.2.0-rc.8", + "block-buffer 0.12.0", + "crypto-common 0.2.2", "inout", ] @@ -730,7 +732,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -750,9 +752,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.0-pre.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417da527aa9bf6a1e10a781231effd1edd3ee82f27d5f8529ac9b279babce96" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "colorchoice" @@ -904,6 +906,12 @@ dependencies = [ "windows", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -913,6 +921,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1023,15 +1040,16 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.7.0-rc.18" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37387ceb32048ff590f2cbd24d8b05fffe63c3f69a5cfa089d4f722ca4385a19" +checksum = "42a0d26b245348befa0c121944541476763dcc46ede886c88f9d12e1697d27c3" dependencies = [ + "cpubits", "ctutils", - "getrandom 0.4.0-rc.0", + "getrandom 0.4.2", "hybrid-array", "num-traits", - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", "serdect", "subtle", "zeroize", @@ -1049,13 +1067,13 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.0-rc.8" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6165b8029cdc3e765b74d3548f85999ee799d5124877ce45c2c85ca78e4d4aa" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.0-rc.0", + "getrandom 0.4.2", "hybrid-array", - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", ] [[package]] @@ -1070,13 +1088,13 @@ dependencies = [ [[package]] name = "crypto-primes" -version = "0.7.0-pre.6" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79c98a281f9441200b24e3151407a629bfbe720399186e50516da939195e482" +checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" dependencies = [ "crypto-bigint", "libm", - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", ] [[package]] @@ -1112,18 +1130,18 @@ dependencies = [ [[package]] name = "ctr" -version = "0.10.0-rc.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0ec605a95e78815a4c4b8040217d56d5a1ab37043851ee9e7e65b89afa00e3" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ "cipher", ] [[package]] name = "ctutils" -version = "0.3.2" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758e5ed90be3c8abff7f9a6f37ab7f6d8c59c2210d448b81f3f508134aec84e4" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", "subtle", @@ -1137,14 +1155,14 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "curve25519-dalek" -version = "5.0.0-pre.4" +version = "5.0.0-pre.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ae8b2fe5e4995d7fd08a7604e794dc569a65ed19659f5939d529813ed816d38" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest 0.11.0-rc.5", + "digest 0.11.3", "fiat-crypto", "rustc_version", "subtle", @@ -1159,7 +1177,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1189,9 +1207,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.0-rc.10" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c1d73e9668ea6b6a28172aa55f3ebec38507131ce179051c8033b5c6037653" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ "const-oid 0.10.2", "pem-rfc7468 1.0.0", @@ -1219,7 +1237,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1239,7 +1257,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1261,14 +1279,14 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", ] [[package]] name = "des" -version = "0.9.0-rc.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f51594a70805988feb1c85495ddec0c2052e4fbe59d9c0bb7f94bfc164f4f90" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" dependencies = [ "cipher", ] @@ -1291,14 +1309,14 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.0-rc.5" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebf9423bafb058e4142194330c52273c343f8a5beb7176d052f0e73b17dd35b9" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.11.0", + "block-buffer 0.12.0", "const-oid 0.10.2", - "crypto-common 0.2.0-rc.8", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1309,7 +1327,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1329,7 +1347,7 @@ dependencies = [ "serde", "smallvec", "strck_ident", - "syn", + "syn 2.0.117", ] [[package]] @@ -1356,7 +1374,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1455,24 +1473,24 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" -version = "0.17.0-rc.11" +version = "0.17.0-rc.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "569a1f3377df19ab839b2811061095ff7d9fb7ea3c0e500b7a4724343cf6ee3d" +checksum = "dc4bf51f0534ed6e59a0f2f26272b64ba55c470133f8424c2adfd1c4d59d9988" dependencies = [ - "der 0.8.0-rc.10", - "digest 0.11.0-rc.5", + "der 0.8.0", + "digest 0.11.3", "elliptic-curve", "rfc6979", "signature", - "spki 0.8.0-rc.4", + "spki 0.8.0", "zeroize", ] [[package]] name = "ed25519" -version = "3.0.0-rc.2" +version = "3.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "594435fe09e345ee388e4e8422072ff7dfeca8729389fbd997b3f5504c44cd47" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" dependencies = [ "pkcs8", "signature", @@ -1480,14 +1498,14 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "3.0.0-pre.4" +version = "3.0.0-pre.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4b9f613e0c236c699bf70d39f825594d9b03aadfd8dd856ea40685f782a4ef2" +checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core 0.10.0-rc-3", - "sha2 0.11.0-rc.3", + "rand_core 0.10.1", + "sha2 0.11.0", "subtle", "zeroize", ] @@ -1500,20 +1518,20 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" -version = "0.14.0-rc.19" +version = "0.14.0-rc.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bfae4ab886ff791e2119cc79402281e35408f22b6b7322acef371d01061054b" +checksum = "b148a81cede8f4023248f980cffdf7611c46f2add469c6980e815b7c5b764ba5" dependencies = [ "base16ct", "crypto-bigint", - "digest 0.11.0-rc.5", - "getrandom 0.4.0-rc.0", + "crypto-common 0.2.2", + "digest 0.11.3", "hkdf", "hybrid-array", "once_cell", "pem-rfc7468 1.0.0", "pkcs8", - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", "rustcrypto-ff", "rustcrypto-group", "sec1", @@ -1640,6 +1658,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1667,7 +1691,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1759,7 +1783,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1848,31 +1872,32 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.0-rc.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b99f0d993a2b9b97b9a201193aa8ad21305cde06a3be9a7e1f8f4201e5cc27e" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core 0.10.0-rc-3", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", + "wasip3", "wasm-bindgen", ] [[package]] name = "ghash" -version = "0.6.0-rc.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333de57ed9494a40df4bbb866752b100819dde0d18f2264c48f5a08a85fe673d" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ "polyval", ] @@ -1976,6 +2001,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -2015,27 +2049,27 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.13.0-rc.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfbb4225acf2b5cc4e12d384672cd6d1f0cb980ff5859ffcf144db25b593a24d" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.13.0-rc.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c597ac7d6cc8143e30e83ef70915e7f883b18d8bec2e2b2bce47f5bbb06d57" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.0-rc.5", + "digest 0.11.3", ] [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -2265,6 +2299,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -2306,7 +2346,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -2694,7 +2736,7 @@ dependencies = [ "ironrdp-core", "ironrdp-error", "md-5 0.10.6", - "num-bigint", + "num-bigint 0.4.6", "num-derive", "num-integer", "num-traits", @@ -2976,7 +3018,7 @@ dependencies = [ "futures-util", "getrandom 0.2.17", "getrandom 0.3.4", - "getrandom 0.4.0-rc.0", + "getrandom 0.4.2", "gloo-net", "gloo-timers", "iron-remote-desktop", @@ -3088,7 +3130,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -3116,7 +3158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3143,11 +3185,12 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0-rc.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d546793a04a1d3049bd192856f804cfe96356e2cf36b54b4e575155babe9f41" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ - "cpufeatures", + "cfg-if", + "cpufeatures 0.3.0", ] [[package]] @@ -3156,6 +3199,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -3293,12 +3342,12 @@ dependencies = [ [[package]] name = "md-5" -version = "0.11.0-rc.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9ec86664728010f574d67ef01aec964e6f1299241a3402857c1a8a390a62478" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.0-rc.5", + "digest 0.11.3", ] [[package]] @@ -3450,6 +3499,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3474,7 +3534,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3514,7 +3574,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3920,7 +3980,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3971,43 +4031,43 @@ dependencies = [ [[package]] name = "p256" -version = "0.14.0-rc.3" +version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4caab26e75ab3d0790a0f29df73f006308ada2e1fbbfcbab03e92346adc43dd9" +checksum = "8b97e3bf0465157ae90975ff52dbeb1362ba618924878c9f74c25baa27a65f9a" dependencies = [ "ecdsa", "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0-rc.3", + "sha2 0.11.0", ] [[package]] name = "p384" -version = "0.14.0-rc.3" +version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30732b26c446549117425e4e905456494ff6c87da40216fd6c71ccc7c3976080" +checksum = "437f30ebcb1e16ff48acead5f08bd69fbcdbc82421687bb48af5c315a0bfab03" dependencies = [ "ecdsa", "elliptic-curve", "fiat-crypto", "primefield", "primeorder", - "sha2 0.11.0-rc.3", + "sha2 0.11.0", ] [[package]] name = "p521" -version = "0.14.0-rc.3" +version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dd4780ae0e4fc6a0a722123508ee69e88d45f94bdff67ef25528f659dda9dbd" +checksum = "4e9fd792bab86ecf6249561752fb5a413511f999887107dd054bbda5143743d7" dependencies = [ "base16ct", "ecdsa", "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0-rc.3", + "sha2 0.11.0", ] [[package]] @@ -4051,13 +4111,12 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbkdf2" -version = "0.13.0-rc.1" +version = "0.13.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3fc18bb4460ac250ba6b75dfa7cf9d0b2273e3e623f660bd6ce2c3e902342e" +checksum = "1f24f3eb2f4471b1730d59e4b730b747939960a8c7eb0c33c5a9076f2d3dddea" dependencies = [ - "digest 0.11.0-rc.5", + "digest 0.11.3", "hmac", - "sha1 0.11.0-rc.3", ] [[package]] @@ -4086,41 +4145,33 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "picky" -version = "7.0.0-rc.22" +version = "7.0.0-rc.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d077505451145769907bee30661be7182a24c75e97f040ccaf5848fd5de928" +checksum = "be8b243c0a8e59483c7b0f746aed1c1719245692a92e9a35693f9edb88a0b712" dependencies = [ "aead", "aes", "aes-gcm", "aes-kw", "base64", - "block-buffer 0.11.0", - "block-padding", "cbc", - "cipher", "crypto-bigint", - "crypto-common 0.2.0-rc.8", - "crypto-primes", + "crypto-common 0.2.2", "ctr", "curve25519-dalek", - "der 0.8.0-rc.10", "des", - "digest 0.11.0-rc.5", + "digest 0.11.3", "ecdsa", "ed25519", "ed25519-dalek", "elliptic-curve", "ff", - "ghash", "group", "hex", - "hkdf", "hmac", "http", "inout", - "keccak", - "md-5 0.11.0-rc.2", + "md-5 0.11.0", "p256", "p384", "p521", @@ -4130,24 +4181,23 @@ dependencies = [ "picky-asn1-x509", "pkcs1 0.8.0-rc.4", "pkcs8", - "polyval", "primefield", "primeorder", - "rand 0.10.0-rc.6", - "rand_core 0.10.0-rc-3", + "rand 0.10.1", + "rand_core 0.10.1", "rc2", "rfc6979", "rsa", - "sec1", + "rustcrypto-ff", + "rustcrypto-ff_derive", + "rustcrypto-group", "serde", "serde_json", - "sha1 0.11.0-rc.3", - "sha2 0.11.0-rc.3", + "sha1 0.11.0", + "sha2 0.11.0", "sha3", "signature", - "spki 0.8.0-rc.4", "thiserror 2.0.18", - "universal-hash", "x25519-dalek", "zeroize", ] @@ -4178,9 +4228,9 @@ dependencies = [ [[package]] name = "picky-asn1-x509" -version = "0.15.3" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cacf27a75aa1b95e8b1726569fcd693a19d3445aecbcd0d20120e7f82a3cb3b" +checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8" dependencies = [ "base64", "crypto-bigint", @@ -4194,20 +4244,17 @@ dependencies = [ [[package]] name = "picky-krb" -version = "0.12.2" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1647012bc081792da896ec538040c34aac79ce6b11cfdd68029b3baa06b0453f" +checksum = "43602452fdea9ee3fa4141a918c3659bc43d0277143e4ee06be89e26c22e8676" dependencies = [ "aes", - "block-buffer 0.11.0", "block-padding", "byteorder", "cbc", "cipher", "crypto-bigint", - "crypto-common 0.2.0-rc.8", "des", - "digest 0.11.0-rc.5", "hmac", "inout", "oid", @@ -4215,9 +4262,10 @@ dependencies = [ "picky-asn1", "picky-asn1-der", "picky-asn1-x509", - "rand 0.10.0-rc.6", + "rand 0.10.1", + "rand_core 0.10.1", "serde", - "sha1 0.11.0-rc.3", + "sha1 0.11.0", "thiserror 2.0.18", "uuid", ] @@ -4245,7 +4293,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4276,18 +4324,18 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der 0.8.0-rc.10", - "spki 0.8.0-rc.4", + "der 0.8.0", + "spki 0.8.0", ] [[package]] name = "pkcs8" -version = "0.11.0-rc.8" +version = "0.11.0-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77089aec8290d0b7bb01b671b091095cf1937670725af4fd73d47249f03b12c0" +checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" dependencies = [ - "der 0.8.0-rc.10", - "spki 0.8.0-rc.4", + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -4359,12 +4407,12 @@ dependencies = [ [[package]] name = "polyval" -version = "0.7.0-rc.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad60831c19edda4b20878a676595c357e93a9b4e6dca2ba98d75b01066b317b" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ - "cfg-if", - "cpufeatures", + "cpubits", + "cpufeatures 0.3.0", "universal-hash", ] @@ -4411,14 +4459,25 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "primefield" -version = "0.14.0-rc.3" +version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29b2bd4ddf14d08c2bc8d9cceaf362f28c146b0737d58c7fee6534b99e19a3ee" +checksum = "1b52e6ee42db392378a95622b463c9740631171d1efce43fa445a569c1600cb6" dependencies = [ "crypto-bigint", - "rand_core 0.10.0-rc-3", + "crypto-common 0.2.2", + "rand_core 0.10.1", "rustcrypto-ff", "subtle", "zeroize", @@ -4426,9 +4485,9 @@ dependencies = [ [[package]] name = "primeorder" -version = "0.14.0-rc.3" +version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e56388fad6b8c7576e6987fd0c8c7f3bf94d73d74ae794edaac3e420f9cabfe" +checksum = "0556580e42c19833f5d232aca11a7687a503ee41f937b54f5ae1d50fc2a6a36a" dependencies = [ "elliptic-curve", ] @@ -4576,6 +4635,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -4605,13 +4670,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0-rc.6" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bccc05ac8fad6ee391f3cc6725171817eed960345e2fb42ad229d486c1ca2d98" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.0-rc.0", - "rand_core 0.10.0-rc-3", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -4654,9 +4719,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0-rc-3" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66ee92bc15280519ef199a274fe0cafff4245d31bc39aaa31c011ad56cb1f05" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" @@ -4695,9 +4760,9 @@ dependencies = [ [[package]] name = "rc2" -version = "0.9.0-pre.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03621ac292cc723def9e0fd0eb9573b1df8d6a9ee7ad637fe94dfc153705f3c" +checksum = "ceda21af1ae61033b63175653a1af86cae399d79cd03ca80ba347eb3a6c4a7fe" dependencies = [ "cipher", ] @@ -4819,9 +4884,9 @@ dependencies = [ [[package]] name = "rfc6979" -version = "0.5.0-rc.3" +version = "0.5.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8e2323084c987a72875b2fd682b7307d5cf14d47e3875bb5e89948e8809d4" +checksum = "23a3127ee32baec36af75b4107082d9bd823501ec14a4e016be4b6b37faa74ae" dependencies = [ "hmac", "subtle", @@ -4852,19 +4917,19 @@ dependencies = [ [[package]] name = "rsa" -version = "0.10.0-rc.12" +version = "0.10.0-rc.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9a2b1eacbc34fbaf77f6f1db1385518446008d49b9f9f59dc9d1340fce4ca9e" +checksum = "87ed3e93fc7e473e464b9726f4759659e72bc8665e4b8ea227547024f416d905" dependencies = [ "const-oid 0.10.2", "crypto-bigint", "crypto-primes", - "digest 0.11.0-rc.5", + "digest 0.11.3", "pkcs1 0.8.0-rc.4", "pkcs8", - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", "signature", - "spki 0.8.0-rc.4", + "spki 0.8.0", "zeroize", ] @@ -4893,7 +4958,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.117", "unicode-ident", ] @@ -4914,21 +4979,38 @@ dependencies = [ [[package]] name = "rustcrypto-ff" -version = "0.14.0-pre.0" +version = "0.14.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9cd37111549306f79b09aa2618e15b1e8241b7178c286821e3dd71579db4db" +checksum = "fd2a8adb347447693cd2ba0d218c4b66c62da9b0a5672b17b981e4291ec65ff6" dependencies = [ - "rand_core 0.10.0-rc-3", + "bitvec", + "rand_core 0.10.1", + "rustcrypto-ff_derive", "subtle", ] +[[package]] +name = "rustcrypto-ff_derive" +version = "0.14.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cda22ea03582974ab5687fc131eba2dc78e258e7eef4d7e01bcd0522ed79f66" +dependencies = [ + "addchain", + "num-bigint 0.3.3", + "num-integer", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rustcrypto-group" -version = "0.14.0-pre.0" +version = "0.14.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e394cd734b5f97dfc3484fa42aad7acd912961c2bcd96c99aa05b3d6cab7cafd" +checksum = "369f9b61aa45933c062c9f6b5c3c50ab710687eca83dd3802653b140b43f85ed" dependencies = [ - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", "rustcrypto-ff", "subtle", ] @@ -5105,13 +5187,13 @@ dependencies = [ [[package]] name = "sec1" -version = "0.8.0-rc.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2568531a8ace88b848310caa98fb2115b151ef924d54aa523e659c21b9d32d71" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", "ctutils", - "der 0.8.0-rc.10", + "der 0.8.0", "hybrid-array", "subtle", "zeroize", @@ -5192,7 +5274,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5246,19 +5328,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] [[package]] name = "sha1" -version = "0.11.0-rc.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa1ae819b9870cadc959a052363de870944a1646932d274a4e270f64bf79e5ef" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest 0.11.0-rc.5", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5268,28 +5350,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] [[package]] name = "sha2" -version = "0.11.0-rc.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d43dc0354d88b791216bb5c1bfbb60c0814460cc653ae0ebd71f286d0bd927" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest 0.11.0-rc.5", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "sha3" -version = "0.11.0-rc.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2103ca0e6f4e9505eae906de5e5883e06fc3b2232fb5d6914890c7bbcb62f478" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ - "digest 0.11.0-rc.5", + "digest 0.11.3", "keccak", ] @@ -5341,12 +5423,12 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.6" +version = "3.0.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a96996ccff7dfa16f052bd995b4cecc72af22c35138738dc029f0ead6608d" +checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" dependencies = [ - "digest 0.11.0-rc.5", - "rand_core 0.10.0-rc-3", + "digest 0.11.3", + "rand_core 0.10.1", ] [[package]] @@ -5480,42 +5562,37 @@ dependencies = [ [[package]] name = "spki" -version = "0.8.0-rc.4" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.0-rc.10", + "der 0.8.0", ] [[package]] name = "sspi" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d4e729e937757a093b8ebe84933747db961e2f20744f957f5e6fbf6e0e2610" +checksum = "3db83308ba07f6c54141f7e34a167353f81250fe8ccab87e90c323f4390b0fb0" dependencies = [ "async-dnssd", "async-recursion", "bitflags 2.11.1", - "block-buffer 0.12.0", "bytemuck", "byteorder", "cfg-if", "crypto-bigint", - "crypto-common 0.2.0-rc.8", "crypto-mac", - "crypto-primes", "cryptoki", "curve25519-dalek", - "der 0.8.0-rc.10", - "digest 0.11.0-rc.5", "ed25519-dalek", "ff", "futures", "getrandom 0.3.4", "group", "hmac", - "md-5 0.11.0-rc.2", + "md-5 0.11.0", "md4", "num-derive", "num-traits", @@ -5523,7 +5600,6 @@ dependencies = [ "p256", "p384", "p521", - "pem-rfc7468 1.0.0", "picky", "picky-asn1", "picky-asn1-der", @@ -5534,16 +5610,19 @@ dependencies = [ "portpicker", "primefield", "primeorder", - "rand 0.10.0-rc.6", + "rand 0.10.1", + "rand_core 0.10.1", "reqwest", "rsa", + "rustcrypto-ff", + "rustcrypto-ff_derive", + "rustcrypto-group", "rustls", "rustls-native-certs", "serde", - "sha1 0.11.0-rc.3", - "sha2 0.11.0-rc.3", + "sha1 0.11.0", + "sha2 0.11.0", "signature", - "spki 0.8.0-rc.4", "time", "tokio", "tracing", @@ -5596,6 +5675,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -5624,7 +5714,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5661,7 +5751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -5693,7 +5783,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5704,7 +5794,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5845,7 +5935,7 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5873,7 +5963,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6043,7 +6133,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6175,14 +6265,20 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" -version = "0.6.0-rc.4" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0386f227888b17b65d3e38219a7d41185035471300855c285667811907bb1677" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.2.0-rc.8", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -6253,7 +6349,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6325,7 +6421,16 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -6379,7 +6484,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -6392,6 +6497,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.15" @@ -6643,7 +6782,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6654,7 +6793,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7015,9 +7154,9 @@ dependencies = [ [[package]] name = "winscard" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339bcf57dd0c2341c7ac559b146a4d7e35378cefe3d8729bf028820e856bd578" +checksum = "1210bde4c851460210856b10dbbbad824f9e1e635794f44cf2ce552972521e44" dependencies = [ "bitflags 2.11.1", "crypto-bigint", @@ -7029,19 +7168,107 @@ dependencies = [ "picky", "picky-asn1-x509", "rsa", - "sha1 0.11.0-rc.3", + "sha1 0.11.0", "time", "tracing", "uuid", "widestring", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" @@ -7091,12 +7318,12 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "x25519-dalek" -version = "3.0.0-pre.4" +version = "3.0.0-pre.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5887899407ca8fb861126d509bb08465c14a9c60fad1f24c59ed59630a45586" +checksum = "b3d5d6ff67acd3945b933e592bfa7143db4fcbb2f871754b6b9fbd7847fc5aea" dependencies = [ "curve25519-dalek", - "rand_core 0.10.0-rc-3", + "rand_core 0.10.1", "zeroize", ] @@ -7187,7 +7414,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -7217,7 +7444,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7237,7 +7464,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -7258,7 +7485,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7291,7 +7518,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/crates/ironrdp-connector/Cargo.toml b/crates/ironrdp-connector/Cargo.toml index 127620dfe2..1cea6f5b31 100644 --- a/crates/ironrdp-connector/Cargo.toml +++ b/crates/ironrdp-connector/Cargo.toml @@ -26,13 +26,13 @@ ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["std"] } # public -sspi = { version = "0.20", features = ["scard"] } +sspi = { version = "0.21", features = ["scard"] } url = "2.5" # public rand = { version = "0.9", features = ["std"] } # TODO: dependency injection? tracing = { version = "0.1", features = ["log"] } picky-asn1-der = "0.5" picky-asn1-x509 = "0.15" -picky = "=7.0.0-rc.22" # FIXME: We are pinning with = because the candidate version number counts as the minor number by Cargo, and will be automatically bumped in the Cargo.lock. +picky = "=7.0.0-rc.23" # FIXME: We are pinning with = because the candidate version number counts as the minor number by Cargo, and will be automatically bumped in the Cargo.lock. [lints] workspace = true diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index df73bdab32..14d5173c71 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -64,7 +64,7 @@ async-trait = "0.1" image = { version = "0.25", default-features = false, features = ["png"] } pico-args = "0.5" x509-cert = { version = "0.2", default-features = false, features = ["std"] } -sspi = { version = "0.20", features = ["network_client"] } +sspi = { version = "0.21", features = ["network_client"] } tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio-rustls = "0.26" diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 2434732338..b4e6f5e547 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -19,7 +19,7 @@ ironrdp-cliprdr-native.path = "../crates/ironrdp-cliprdr-native" ironrdp-dvc-pipe-proxy.path = "../crates/ironrdp-dvc-pipe-proxy" ironrdp-core = { path = "../crates/ironrdp-core", features = ["alloc"] } ironrdp-rdcleanpath.path = "../crates/ironrdp-rdcleanpath" -sspi = { version = "0.20", features = ["network_client"] } +sspi = { version = "0.21", features = ["network_client"] } thiserror = "2" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } From e45f68c7e52297ca50d33b44c0ace36c9940fbe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Wed, 27 May 2026 21:22:19 +0900 Subject: [PATCH 256/325] fix(web-client): add repository metadata to published package.json (#1323) npm's sigstore provenance verification rejected publishing with: E422 Unprocessable Entity - Error verifying sigstore provenance bundle: "repository.url" is "", expected to match "https://github.com/Devolutions/IronRDP" from provenance Add `repository`, `homepage`, `bugs`, and `license` fields to the `public/package.json` of both `iron-remote-desktop` and `iron-remote-desktop-rdp` so the published tarballs satisfy npm's provenance checks. --- web-client/iron-remote-desktop-rdp/public/package.json | 9 +++++++++ web-client/iron-remote-desktop/public/package.json | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/web-client/iron-remote-desktop-rdp/public/package.json b/web-client/iron-remote-desktop-rdp/public/package.json index a4e54dfbd8..0db5a4f255 100644 --- a/web-client/iron-remote-desktop-rdp/public/package.json +++ b/web-client/iron-remote-desktop-rdp/public/package.json @@ -7,6 +7,15 @@ ], "description": "RDP backend for iron-remote-desktop", "version": "0.7.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Devolutions/IronRDP.git" + }, + "homepage": "https://github.com/Devolutions/IronRDP", + "bugs": { + "url": "https://github.com/Devolutions/IronRDP/issues" + }, + "license": "MIT OR Apache-2.0", "main": "iron-remote-desktop-rdp.js", "types": "index.d.ts", "files": [ diff --git a/web-client/iron-remote-desktop/public/package.json b/web-client/iron-remote-desktop/public/package.json index 327881b78b..25c7e428fa 100644 --- a/web-client/iron-remote-desktop/public/package.json +++ b/web-client/iron-remote-desktop/public/package.json @@ -11,6 +11,15 @@ ], "description": "Backend-agnostic Web Component for remote desktop protocols", "version": "0.11.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Devolutions/IronRDP.git" + }, + "homepage": "https://github.com/Devolutions/IronRDP", + "bugs": { + "url": "https://github.com/Devolutions/IronRDP/issues" + }, + "license": "MIT OR Apache-2.0", "main": "iron-remote-desktop.js", "types": "index.d.ts", "files": [ From aa7ff679b914dbbc9bfe137d7f4f26bea30d6323 Mon Sep 17 00:00:00 2001 From: clintcan Date: Wed, 27 May 2026 21:22:27 +0800 Subject: [PATCH 257/325] feat(server): handle SuppressOutput / RefreshRectangle and expose state (#1319) --- crates/ironrdp-server/src/builder.rs | 26 ++++++ crates/ironrdp-server/src/capabilities.rs | 8 ++ crates/ironrdp-server/src/server.rs | 101 +++++++++++++++++++++- 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index 9700af790a..cc5d8e8b4f 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -1,4 +1,6 @@ use core::net::SocketAddr; +use core::sync::atomic::AtomicBool; +use std::sync::Arc; use anyhow::Result; use ironrdp_pdu::rdp::capability_sets::{BitmapCodecs, server_codecs_capabilities}; @@ -37,6 +39,7 @@ pub struct BuilderDone { connection_handler: Option>, #[cfg(feature = "egfx")] gfx_factory: Option>, + display_suppressed: Option>, } pub struct RdpServerBuilder { @@ -134,6 +137,7 @@ impl RdpServerBuilder { max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] gfx_factory: None, + display_suppressed: None, }, } } @@ -152,6 +156,7 @@ impl RdpServerBuilder { max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] gfx_factory: None, + display_suppressed: None, }, } } @@ -198,6 +203,26 @@ impl RdpServerBuilder { self } + /// Share the server's "display suppressed" flag with the display + /// backend before construction. + /// + /// The flag is `true` while the connected client has sent + /// `SuppressOutput { desktop_rect: None }` (e.g., mstsc minimized). + /// Display backends that want to skip frame emission while the + /// client is minimized create one `Arc` in the + /// application, hand a clone to the display, and pass the same + /// `Arc` here so the server's per-connection PDU handler writes to + /// the same instance the backend reads. + /// + /// When this is not called, the server allocates its own internal + /// flag (still readable via [`RdpServer::display_suppressed_handle`]) + /// — useful when the backend can call `display_suppressed_handle()` + /// after construction to obtain a handle, rather than sharing one in. + pub fn with_display_suppressed_handle(mut self, handle: Arc) -> Self { + self.state.display_suppressed = Some(handle); + self + } + pub fn build(self) -> RdpServer { RdpServer::new( RdpServerOptions { @@ -213,6 +238,7 @@ impl RdpServerBuilder { self.state.connection_handler, #[cfg(feature = "egfx")] self.state.gfx_factory, + self.state.display_suppressed, ) } } diff --git a/crates/ironrdp-server/src/capabilities.rs b/crates/ironrdp-server/src/capabilities.rs index 5a5e4adf5b..2622c88b4d 100644 --- a/crates/ironrdp-server/src/capabilities.rs +++ b/crates/ironrdp-server/src/capabilities.rs @@ -19,6 +19,14 @@ pub(crate) fn capabilities(opts: &RdpServerOptions, size: DesktopSize) -> Vec capability_sets::General { capability_sets::General { extra_flags: GeneralExtraFlags::FASTPATH_OUTPUT_SUPPORTED, + // Advertise that the server handles `SuppressOutput` and + // `RefreshRectangle` (per MS-RDPBCGR 2.2.7.1.1) — spec-compliant + // clients only send these PDUs when the server says it supports + // them. mstsc sends them regardless, but FreeRDP and others + // follow the spec; without both flags, those clients never + // benefit from the minimize→refocus backlog fix. + refresh_rect_support: true, + suppress_output_support: true, ..Default::default() } } diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 6dee90d233..23149c8b61 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1,4 +1,5 @@ use core::net::SocketAddr; +use core::sync::atomic::{AtomicBool, Ordering}; use core::time::Duration; use std::rc::Rc; use std::sync::Arc; @@ -11,6 +12,7 @@ use ironrdp_cliprdr::backend::ClipboardMessage; use ironrdp_core::{decode, encode_vec, impl_as_any}; use ironrdp_displaycontrol::pdu::DisplayControlMonitorLayout; use ironrdp_displaycontrol::server::{DisplayControlHandler, DisplayControlServer}; +use ironrdp_dvc as dvc; use ironrdp_pdu::input::InputEventPdu; use ironrdp_pdu::input::fast_path::{FastPathInput, FastPathInputEvent}; use ironrdp_pdu::mcs::{SendDataIndication, SendDataRequest}; @@ -19,6 +21,7 @@ pub use ironrdp_pdu::rdp::client_info::Credentials; use ironrdp_pdu::rdp::headers::{ServerDeactivateAll, ShareControlPdu}; use ironrdp_pdu::x224::X224; use ironrdp_pdu::{Action, PduResult, decode_err, mcs, nego, rdp}; +use ironrdp_rdpsnd as rdpsnd; use ironrdp_svc::{ChannelFlags, StaticChannelId, StaticChannelSet, SvcProcessor, server_encode_svc_messages}; use ironrdp_tokio::{FramedRead, FramedWrite, TokioFramed, split_tokio_framed, unsplit_tokio_framed}; use rdpsnd::server::{RdpsndServer, RdpsndServerMessage}; @@ -28,7 +31,6 @@ use tokio::sync::{Mutex, mpsc, oneshot}; use tokio::task; use tokio_rustls::TlsAcceptor; use tracing::{debug, error, trace, warn}; -use {ironrdp_dvc as dvc, ironrdp_rdpsnd as rdpsnd}; use crate::autodetect::{AutoDetectManager, RttSnapshot}; use crate::clipboard::CliprdrServerFactory; @@ -291,6 +293,17 @@ pub struct RdpServer { local_addr: Option, autodetect: Option, connection_handler: Option>, + /// True while the client has sent `SuppressOutput { desktop_rect: None }` + /// — the standard RDP "I don't need display updates right now" signal + /// (mstsc raises it on window minimize). Cleared on + /// `SuppressOutput { Some(rect) }` or `RefreshRectangle` (sent on + /// refocus). Exposed via [`Self::display_suppressed_handle`] so display + /// backends can hold a clone and skip frame emission while it's set — + /// without this, a server keeps streaming high-bitrate + /// EGFX/H.264 frames into a minimized client, which accumulates them + /// and locks up its input dispatch for seconds on refocus while it + /// chews through the backlog. + display_suppressed: Arc, } #[derive(Debug)] @@ -325,6 +338,16 @@ enum RunState { } impl RdpServer { + // The lint only fires with the `egfx` feature on (8 args including + // `gfx_factory`); without it the parameter count is 7 and the lint + // is satisfied. `cfg_attr` keeps `#[expect]` strict in both modes. + #[cfg_attr( + feature = "egfx", + expect( + clippy::too_many_arguments, + reason = "called via the builder; positional parameters are an internal detail" + ) + )] pub fn new( opts: RdpServerOptions, handler: Box, @@ -333,6 +356,7 @@ impl RdpServer { mut cliprdr_factory: Option>, connection_handler: Option>, #[cfg(feature = "egfx")] mut gfx_factory: Option>, + display_suppressed: Option>, ) -> Self { let (ev_sender, ev_receiver) = ServerEvent::create_channel(); if let Some(cliprdr) = cliprdr_factory.as_mut() { @@ -363,6 +387,7 @@ impl RdpServer { local_addr: None, autodetect: None, connection_handler, + display_suppressed: display_suppressed.unwrap_or_else(|| Arc::new(AtomicBool::new(false))), } } @@ -374,6 +399,39 @@ impl RdpServer { &self.ev_sender } + /// Returns the shared "display suppressed" flag — `true` while the + /// connected client has sent `SuppressOutput { desktop_rect: None }` + /// (e.g., mstsc minimized). + /// + /// Display backends should hold a clone of this `Arc` and skip frame + /// emission while it's set, so the client doesn't accumulate a backlog + /// of frames it can't present until refocus. Cleared by the per- + /// connection PDU handler on `SuppressOutput { Some(rect) }` or + /// `RefreshRectangle`. + /// + /// **Caveat:** some clients (notably mstsc) send + /// `SuppressOutput { desktop_rect: None }` during their connect + /// handshake *before* their display surface is fully initialized; a + /// backend that honors the flag blindly will block that first frame + /// and leave the client with a half-initialized surface that doesn't + /// recover on un-suppress (visible as a frozen desktop on first + /// connect). Backends are advised to defer acting on the flag until + /// after the first frame has been delivered to the client, and to + /// debounce transient flaps (some clients pulse this PDU under wire + /// pressure on heavy CPU/IO loads) — e.g., only engage the gate once + /// the flag has been steady-`true` for ~1 s. + /// + /// The display backend typically needs to share this flag with the + /// server before any client connects (so the same `Arc` is read by + /// the backend's polling thread and written by the per-connection + /// PDU handler). To inject the shared instance at construction time, + /// use [`RdpServerBuilder::with_display_suppressed_handle`](crate::RdpServerBuilder::with_display_suppressed_handle). + /// + /// [crate::RdpServerBuilder]: crate::RdpServerBuilder + pub fn display_suppressed_handle(&self) -> Arc { + Arc::clone(&self.display_suppressed) + } + /// Returns the shared ECHO server handle for runtime probe requests and RTT measurements. pub fn echo_handle(&self) -> &EchoServerHandle { &self.echo_handle @@ -460,6 +518,18 @@ impl RdpServer { where S: AsyncRead + AsyncWrite + Send + Sync + Unpin, { + // Per-connection state must start fresh: if the previous client + // disconnected while it had sent `SuppressOutput { None }` (e.g., + // closed the mstsc window while minimized so the matching resume + // PDU never arrived), the flag would still read `true` here and + // the display backend would silently drop frames for the entire + // new session until/unless the new client happens to send a + // `RefreshRectangle` or `SuppressOutput { Some(rect) }`. Resetting + // here also covers backends that share an externally-created Arc + // via `set_display_suppressed_handle()` — they get the same + // per-connection clean slate. + self.display_suppressed.store(false, Ordering::Relaxed); + let framed = TokioFramed::new(stream); let size = self.display.lock().await.size().await; @@ -1155,6 +1225,35 @@ impl RdpServer { } } + // Client requests the server stop or resume sending display + // updates. mstsc sends `desktop_rect: None` on minimize and + // `desktop_rect: Some(rect)` on refocus. Without honoring + // this, the server keeps streaming high-bitrate EGFX/H.264 + // frames into a minimized client; on refocus the client + // must chew through the accumulated backlog before it can + // present the current frame, locking up its input dispatch + // for seconds. Flagging the shared `display_suppressed` + // lets the display backend skip frame emission while it's + // set. + rdp::headers::ShareDataPdu::SuppressOutput(pdu) => { + let suppress = pdu.desktop_rect.is_none(); + self.display_suppressed.store(suppress, Ordering::Relaxed); + debug!(suppress, "client suppress-output state changed"); + } + + // Client asks the server to redraw a rectangle — typical on + // refocus after a minimize. Clear the suppress flag so the + // backend resumes emission and treat this as "client wants + // updates again." (The flag would also be cleared by the + // `SuppressOutput { Some(rect) }` that usually accompanies + // this; clearing here is belt-and-braces against clients + // that send only one of the two.) + rdp::headers::ShareDataPdu::RefreshRectangle(_) => { + if self.display_suppressed.swap(false, Ordering::Relaxed) { + debug!("client RefreshRectangle cleared suppress-output state"); + } + } + unexpected => { warn!(?unexpected, "Unexpected share data pdu"); } From 30a79a04138db2500421f3852caa1b83ba1dd0b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Wed, 27 May 2026 22:29:13 +0900 Subject: [PATCH 258/325] chore: remove code coverage pipeline (#1324) The cargo-llvm-cov + grcov coverage report pipeline has been broken for a while and is no longer used. Drop the GitHub Actions workflow, the `cargo xtask cov` subcommands and module, the pinned tool versions, and the related `.gitignore` and AGENTS.md entries. --- .github/workflows/coverage.yml | 55 ------ .gitignore | 3 - AGENTS.md | 2 +- xtask/src/bin_version.rs | 2 - xtask/src/cli.rs | 29 --- xtask/src/cov.rs | 338 --------------------------------- xtask/src/main.rs | 7 - 7 files changed, 1 insertion(+), 435 deletions(-) delete mode 100644 .github/workflows/coverage.yml delete mode 100644 xtask/src/cov.rs diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 4f1974d85c..0000000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Coverage - -on: - push: - branches: - - master - pull_request: - types: [opened, synchronize, reopened] - workflow_dispatch: - -env: - CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse - -jobs: - coverage: - name: Coverage Report - runs-on: ubuntu-latest - - # Running the coverage job is only supported on the official repo itself, not on forks - # (because $GITHUB_TOKEN only have read permissions when run on a fork) - # We would need something like Codecov integration to handle forks properly - # https://github.com/taiki-e/cargo-llvm-cov#continuous-integration - if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' - - steps: - - uses: actions/checkout@v6 - - - name: Install devel packages - run: | - sudo apt-get update -qq - sudo apt-get -y install libasound2-dev - - - name: Rust cache - uses: Swatinem/rust-cache@v2.7.3 - - - name: Prepare runner - run: cargo xtask cov install -v - - - name: Generate PR report - if: ${{ github.event.number != '' }} - run: cargo xtask cov report-gh --repo "${{ github.repository }}" --pr "${{ github.event.number }}" -v - env: - GH_TOKEN: ${{ github.token }} - - - name: Configure Git Identity - if: ${{ github.ref == 'refs/heads/master' }} - run: | - git config --local user.name "github-actions[bot]" - git config --local user.email "github-actions[bot]@users.noreply.github.com" - - - name: Update coverage data - if: ${{ github.ref == 'refs/heads/master' }} - run: cargo xtask cov update -v - env: - GH_TOKEN: ${{ secrets.DEVOLUTIONSBOT_TOKEN }} diff --git a/.gitignore b/.gitignore index 5156a02b8f..39f5287459 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,6 @@ # Log files *.log -# Coverage -/docs/coverage - # Editor/IDE files *~ /tags diff --git a/AGENTS.md b/AGENTS.md index 9e317de707..23ac207cfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,7 +189,7 @@ CI runs via GitHub Actions (`.github/workflows/ci.yml`). The expectation is that `cargo xtask ci -v` locally is equivalent to a full CI run. All commands in the Core and Specialized Commands sections above are what CI executes (each preceded by its `install` step where applicable, and `cargo xtask check locks -v` is run in multiple jobs). -Additional workflows exist for releases (`release-crates.yml`), npm (`npm-publish.yml`), NuGet (`nuget-publish.yml`), coverage, and fuzzing. +Additional workflows exist for releases (`release-crates.yml`), npm (`npm-publish.yml`), NuGet (`nuget-publish.yml`), and fuzzing. Do not alter release automation unless explicitly requested. ### Workspace & Change Scope Rules diff --git a/xtask/src/bin_version.rs b/xtask/src/bin_version.rs index c0882acb54..68e0353a2e 100644 --- a/xtask/src/bin_version.rs +++ b/xtask/src/bin_version.rs @@ -5,8 +5,6 @@ use crate::bin_install::CargoPackage; pub const CARGO_FUZZ: CargoPackage = CargoPackage::new("cargo-fuzz", "0.12.0"); pub const CARGO_HACK: CargoPackage = CargoPackage::new("cargo-hack", "0.6.44"); -pub const CARGO_LLVM_COV: CargoPackage = CargoPackage::new("cargo-llvm-cov", "0.6.16"); -pub const GRCOV: CargoPackage = CargoPackage::new("grcov", "0.8.20"); pub const WASM_PACK: CargoPackage = CargoPackage::new("wasm-pack", "0.13.1"); pub const TYPOS_CLI: CargoPackage = CargoPackage::new("typos-cli", "1.29.5").with_binary_name("typos"); pub const DIPLOMAT_TOOL: CargoPackage = CargoPackage::new("diplomat-tool", "0.7.1"); diff --git a/xtask/src/cli.rs b/xtask/src/cli.rs index 241da0aaaa..3c6f780777 100644 --- a/xtask/src/cli.rs +++ b/xtask/src/cli.rs @@ -23,12 +23,6 @@ TASKS: check install Install all requirements for check tasks ci Run all checks required on CI clean Clean workspace - cov grcov Generate a nice HTML report using code-coverage data from tests and fuzz targets - cov install Install cargo-llvm-cov in cargo local root - cov report-gh --repo --pr - Generate a coverage report, posting a comment in GitHub PR - cov report [--html] Generate a coverage report (optionally, a HTML report) - cov update Update coverage data in the cov-data branch fuzz corpus-fetch Fetch fuzzing corpus from Azure storage fuzz corpus-min [--target ] Minify fuzzing corpus for a specific target (or all if unspecified) @@ -98,16 +92,6 @@ pub enum Action { CheckInstall, Ci, Clean, - CovGrcov, - CovInstall, - CovReportGitHub { - repo: String, - pr: u32, - }, - CovReport { - html_report: bool, - }, - CovUpdate, FuzzCorpusFetch, FuzzCorpusMin { target: Option, @@ -163,19 +147,6 @@ pub fn parse_args() -> anyhow::Result { }, Some("ci") => Action::Ci, Some("clean") => Action::Clean, - Some("cov") => match args.subcommand()?.as_deref() { - Some("grcov") => Action::CovGrcov, - Some("install") => Action::CovInstall, - Some("report-gh") => Action::CovReportGitHub { - repo: args.value_from_str("--repo")?, - pr: args.value_from_str("--pr")?, - }, - Some("report") => Action::CovReport { - html_report: args.contains("--html"), - }, - Some("update") => Action::CovUpdate, - None | Some(_) => anyhow::bail!("Unknown cov action"), - }, Some("fuzz") => match args.subcommand()?.as_deref() { Some("corpus-fetch") => Action::FuzzCorpusFetch, Some("corpus-min") => Action::FuzzCorpusMin { diff --git a/xtask/src/cov.rs b/xtask/src/cov.rs deleted file mode 100644 index dd70da1152..0000000000 --- a/xtask/src/cov.rs +++ /dev/null @@ -1,338 +0,0 @@ -use core::fmt; - -use crate::prelude::*; - -const COV_IGNORE_REGEX: &str = - "(crates/ironrdp-(session|.+generators|.+glutin.+|replay|client|fuzzing|tokio|web|futures|tls)|xtask|testsuite)"; - -pub fn install(sh: &Shell) -> anyhow::Result<()> { - let _s = Section::new("COV-INSTALL"); - - cargo_install(sh, &CARGO_LLVM_COV)?; - - Ok(()) -} - -pub fn update(sh: &Shell) -> anyhow::Result<()> { - let _s = Section::new("COV-UPDATE"); - - let report = CoverageReport::generate(sh)?; - println!("New:\n{report}"); - - let initial_branch = cmd!(sh, "git rev-parse --abbrev-ref HEAD").read()?; - - println!("Switch branch"); - let _ = cmd!(sh, "git branch -D cov-data").run(); - cmd!(sh, "git checkout --orphan cov-data").run()?; - - let result = || -> anyhow::Result<()> { - cmd!(sh, "git rm --cached -r .").run()?; - - sh.write_file("./report.json", report.original_json_data)?; - - cmd!(sh, "git add ./report.json").run()?; - cmd!(sh, "git commit -m 'cov: update report data'").run()?; - cmd!(sh, "git push --force --set-upstream origin cov-data").run()?; - - Ok(()) - }(); - - println!("Clean working tree"); - cmd!(sh, "git clean -df").run()?; - - println!("Switch back to initial branch"); - cmd!(sh, "git checkout {initial_branch}").run()?; - - result?; - - Ok(()) -} - -pub fn report(sh: &Shell, html_report: bool) -> anyhow::Result<()> { - let _s = Section::new("COV-REPORT"); - - if html_report { - cmd!(sh, "{CARGO} llvm-cov --html") - .arg("--ignore-filename-regex") - .arg(COV_IGNORE_REGEX) - .run()?; - } else { - let report = CoverageReport::generate(sh)?; - let past_report = CoverageReport::past_report(sh)?; - - println!("Past:\n{past_report}"); - println!("New:\n{report}"); - println!( - "Diff: {:+.2}%", - report.covered_lines_percent - past_report.covered_lines_percent - ); - } - - Ok(()) -} - -pub fn report_github(sh: &Shell, repo: &str, pr_id: u32) -> anyhow::Result<()> { - use core::fmt::Write as _; - - const COMMENT_HEADER: &str = "## Coverage Report :robot: :gear:"; - const DIFF_THRESHOLD: f64 = 0.005; - - let _s = Section::new("COV-REPORT"); - - let report = CoverageReport::generate(sh)?; - let past_report = CoverageReport::past_report(sh)?; - - let diff = report.covered_lines_percent - past_report.covered_lines_percent; - - println!("Past:\n{past_report}"); - println!("New:\n{report}"); - println!("Diff: {diff:+}%"); - - // `GH_TOKEN` environment variable sanity checks - match std::env::var_os("GH_TOKEN") { - Some(value) if value.is_empty() => trace!("WARNING: `GH_TOKEN` environment variable is empty"), - Some(value) if value.is_ascii() => trace!("`GH_TOKEN` environment variable appears to be set properly"), - Some(_) => trace!("WARNING: `GH_TOKEN` environment variable's value is not an ASCII string"), - None => trace!("WARNING: `GH_TOKEN` environment variable is not set"), - } - - let comments = cmd!(sh, "gh api") - .arg("-H") - .arg("Accept: application/vnd.github.v3+json") - .arg(format!("/repos/{repo}/issues/{pr_id}/comments")) - .read()?; - - let comments: tinyjson::JsonValue = comments.parse().context("GitHub comments")?; - let comments = comments.get::>().context("comments list")?; - - let mut prev_comment_id = None; - - for comment in comments { - let body = comment["body"].get::().context("comment body")?; - - if body.starts_with(COMMENT_HEADER) { - let comment_id = get_json_int(comment, "id")?; - prev_comment_id = Some(comment_id); - break; - } - } - - let mut body = String::new(); - - writeln!(body, "{COMMENT_HEADER}")?; - writeln!(body, "**Past**:\n{past_report}")?; - writeln!(body, "**New**:\n{report}")?; - writeln!(body, "**Diff**: {diff:+.2}%")?; - writeln!(body, "\n[this comment will be updated automatically]")?; - - let command = cmd!(sh, "gh api") - .arg("-H") - .arg("Accept: application/vnd.github.v3+json") - .arg("-f") - .arg(format!("body={body}")); - - if let Some(comment_id) = prev_comment_id { - println!("Update existing comment"); - - command - .arg("--method") - .arg("PATCH") - .arg(format!("/repos/{repo}/issues/comments/{comment_id}")) - .ignore_stdout() - .run()?; - } else if diff.abs() > DIFF_THRESHOLD { - trace!("Diff ({diff}) is greater than threshold ({DIFF_THRESHOLD})"); - println!("Create new comment"); - - command - .arg("--method") - .arg("POST") - .arg(format!("/repos/{repo}/issues/{pr_id}/comments")) - .ignore_stdout() - .run()?; - } else { - println!("Coverage didn't change, skip GitHub comment"); - } - - Ok(()) -} - -pub fn grcov(sh: &Shell) -> anyhow::Result<()> { - let _s = Section::new("COV-GRCOV"); - - cmd!(sh, "rustup install {NIGHTLY_TOOLCHAIN} --profile=minimal").run()?; - cmd!( - sh, - "rustup component add --toolchain {NIGHTLY_TOOLCHAIN} llvm-tools-preview" - ) - .run()?; - cmd!(sh, "rustup component add llvm-tools-preview").run()?; - - cargo_install(sh, &CARGO_FUZZ)?; - cargo_install(sh, &GRCOV)?; - - println!("Remove leftovers"); - sh.remove_path("./fuzz/coverage/")?; - sh.remove_path("./coverage/")?; - - sh.create_dir("./coverage/binaries")?; - - if cfg!(not(target_os = "windows")) { - // Fuzz coverage - - let _guard = sh.push_dir("./fuzz"); - - cmd!(sh, "{CARGO} clean").run()?; - - for target in crate::fuzz::discover_targets()? { - cmd!(sh, "rustup run {NIGHTLY_TOOLCHAIN} cargo fuzz coverage {target}").run()?; - } - - cmd!(sh, "cp -r ./target ../coverage/binaries/").run()?; - } - - { - // Test coverage - - cmd!(sh, "{CARGO} clean").run()?; - - cmd!(sh, "rustup run {NIGHTLY_TOOLCHAIN} cargo test --workspace") - .env("CARGO_INCREMENTAL", "0") - .env("RUSTFLAGS", "-C instrument-coverage") - .env("LLVM_PROFILE_FILE", "./coverage/default-%m-%p.profraw") - .run()?; - - cmd!(sh, "cp -r ./target/debug ./coverage/binaries/").run()?; - } - - sh.create_dir("./docs")?; - - cmd!( - sh, - "grcov . ./fuzz - --source-dir . - --binary-path ./coverage/binaries/ - --output-type html - --branch - --ignore-not-existing - --ignore xtask/* - --ignore src/* - --ignore **/tests/* - --ignore crates/*-generators/* - --ignore crates/web/* - --ignore crates/client/* - --ignore crates/glutin-renderer/* - --ignore crates/glutin-client/* - --ignore crates/replay-client/* - --ignore crates/tls/* - --ignore fuzz/fuzz_targets/* - --ignore target/* - --ignore fuzz/target/* - --excl-start begin-no-coverage - --excl-stop end-no-coverage - -o ./docs/coverage" - ) - .run()?; - - println!("Code coverage report available in `./docs/coverage` folder"); - - println!("Clean up"); - - sh.remove_path("./coverage/")?; - sh.remove_path("./fuzz/coverage/")?; - sh.remove_path("./xtask/coverage/")?; - - sh.read_dir("./crates")? - .into_iter() - .try_for_each(|crate_path| -> xshell::Result<()> { - for path in sh.read_dir(crate_path)? { - if path.ends_with("coverage") { - sh.remove_path(path)?; - } - } - Ok(()) - })?; - - Ok(()) -} - -struct CoverageReport { - total_lines: u64, - covered_lines: u64, - covered_lines_percent: f64, - original_json_data: String, -} - -impl CoverageReport { - fn from_json_value(lines: &tinyjson::JsonValue) -> anyhow::Result { - let total_lines = get_json_int(lines, "count")?; - let covered_lines = get_json_int(lines, "covered")?; - let covered_lines_percent = get_json_float(lines, "percent")?; - - let original_json_data = lines.stringify().context("original json data")?; - - Ok(Self { - total_lines, - covered_lines, - covered_lines_percent, - original_json_data, - }) - } - - fn generate(sh: &Shell) -> anyhow::Result { - let output = cmd!( - sh, - "{CARGO} llvm-cov - --ignore-filename-regex {COV_IGNORE_REGEX} - --json" - ) - .read()?; - - let report: tinyjson::JsonValue = output.parse().context("invalid JSON from cargo-llvm-cov")?; - - let lines = &report["data"][0]["totals"]["lines"]; - - Self::from_json_value(lines) - } - - fn past_report(sh: &Shell) -> anyhow::Result { - cmd!(sh, "git fetch origin cov-data").run()?; - - let output = cmd!(sh, "git show origin/cov-data:report.json").read()?; - - let lines: tinyjson::JsonValue = output - .parse() - .context("invalid JSON from origin/cov-data:report.json")?; - - Self::from_json_value(&lines) - } -} - -impl fmt::Display for CoverageReport { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Total lines: {}", self.total_lines)?; - writeln!( - f, - "Covered lines: {} ({:.2}%)", - self.covered_lines, self.covered_lines_percent - )?; - Ok(()) - } -} - -fn get_json_float(value: &tinyjson::JsonValue, key: &str) -> anyhow::Result { - value[key] - .get::() - .copied() - .with_context(|| format!("invalid value for `{key}`")) -} - -fn get_json_int(value: &tinyjson::JsonValue, key: &str) -> anyhow::Result { - #[expect( - clippy::as_conversions, - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - reason = "tinyjson does not expose any integers at all, so we need the f64 to u64 as casting" - )] - get_json_float(value, key).map(|value| value as u64) -} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index dbe8255ad7..4d60995c98 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,7 +9,6 @@ mod bin_version; mod check; mod clean; mod cli; -mod cov; mod features; mod ffi; mod fuzz; @@ -51,7 +50,6 @@ fn main() -> anyhow::Result<()> { Action::ShowHelp => cli::print_help(), Action::Bootstrap => { check::install(&sh)?; - cov::install(&sh)?; fuzz::install(&sh)?; wasm::install(&sh)?; web::install(&sh)?; @@ -102,11 +100,6 @@ fn main() -> anyhow::Result<()> { check::lock_files(&sh)?; } Action::Clean => clean::workspace(&sh)?, - Action::CovGrcov => cov::grcov(&sh)?, - Action::CovInstall => cov::install(&sh)?, - Action::CovReportGitHub { repo, pr } => cov::report_github(&sh, &repo, pr)?, - Action::CovReport { html_report } => cov::report(&sh, html_report)?, - Action::CovUpdate => cov::update(&sh)?, Action::FuzzCorpusFetch => fuzz::corpus_fetch(&sh)?, Action::FuzzCorpusMin { target } => fuzz::corpus_minify(&sh, target)?, Action::FuzzCorpusPush => fuzz::corpus_push(&sh)?, From ad3a84295ac8904b49e9b1bdafd9b09a28fa0c62 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 27 May 2026 08:49:35 -0500 Subject: [PATCH 259/325] test(fuzz): add egfx_round_trip oracle and target (#1317) --- crates/ironrdp-fuzzing/src/oracles/mod.rs | 43 +++++++++++++++++++ .../egfx_round_trip/seed-empty.bin | 0 .../tests/fuzz_regression.rs | 5 +++ fuzz/Cargo.toml | 7 +++ fuzz/fuzz_targets/egfx_round_trip.rs | 7 +++ 5 files changed, 62 insertions(+) create mode 100644 crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_round_trip/seed-empty.bin create mode 100644 fuzz/fuzz_targets/egfx_round_trip.rs diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index 98654cb923..35891c895f 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -314,6 +314,49 @@ pub fn pdu_round_trip(data: &[u8]) { pdu_round_trip_one!(data, ironrdp_rdpsnd::pdu::ClientAudioOutputPdu); } +/// Round-trip oracle for `ironrdp-egfx` PDU types: `decode` → `encode_vec` → re-`decode`. +/// +/// Same shape and property as [`pdu_round_trip`] but scoped to `ironrdp-egfx`'s +/// own encoder surface. This is the egfx-scoped sibling of the `pdu_round_trip` +/// oracle and the first target under the egfx fuzz-coverage umbrella tracked at +/// the egfx-fuzz issue. +/// +/// Coverage: +/// +/// - `GfxPdu` is the top-level egfx command dispatch and transitively covers +/// `WireToSurface1Pdu`, `WireToSurface2Pdu`, `SolidFillPdu`, +/// `SurfaceToSurfacePdu`, `SurfaceToCachePdu`, `CacheToSurfacePdu`, +/// `EvictCacheEntryPdu`, `CreateSurfacePdu`, `DeleteSurfacePdu`, +/// `StartFramePdu`, `EndFramePdu`, `ResetGraphicsPdu`, +/// `MapSurfaceToOutputPdu`, `MapSurfaceToWindowPdu`, +/// `MapSurfaceToScaledOutputPdu`, `MapSurfaceToScaledWindowPdu`, +/// `FrameAcknowledgePdu`, `QoeFrameAcknowledgePdu`, +/// `DeleteEncodingContextPdu`, `CacheImportOfferPdu`, `CacheImportReplyPdu`. +/// - `CapabilitiesAdvertisePdu` and `CapabilitiesConfirmPdu` exercise the +/// capability-negotiation encoder surface (with `RawCapabilitySet` payloads +/// post-#1305's wire/typed split). +/// - `Avc420BitmapStream` and `Avc444BitmapStream` exercise the H.264 wire +/// container encoder. +/// +/// What this catches: same as `pdu_round_trip` — `unreachable!()` reached on +/// decoder-accepted inputs, integer overflow / OOB in egfx encoders, panics +/// in the decoder when fed encoder-produced bytes. +/// +/// What this does NOT catch: the OpenH264 input-construction wrapper, ZGFX +/// decompression, multi-frame H.264 state. Those are sibling targets in the +/// egfx fuzz-coverage umbrella. +pub fn egfx_round_trip(data: &[u8]) { + use ironrdp_egfx::pdu::{ + Avc420BitmapStream, Avc444BitmapStream, CapabilitiesAdvertisePdu, CapabilitiesConfirmPdu, GfxPdu, + }; + + pdu_round_trip_one!(data, GfxPdu); + pdu_round_trip_one!(data, CapabilitiesAdvertisePdu); + pdu_round_trip_one!(data, CapabilitiesConfirmPdu); + pdu_round_trip_one!(data, Avc420BitmapStream<'_>); + pdu_round_trip_one!(data, Avc444BitmapStream<'_>); +} + pub fn rle_decompress_bitmap(input: BitmapInput<'_>) { let mut out = Vec::new(); diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_round_trip/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_round_trip/seed-empty.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs index 986dd32b66..becbe6ab9b 100644 --- a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs +++ b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs @@ -51,3 +51,8 @@ fn check_bulk_round_trip() { fn check_pdu_round_trip() { check!(pdu_round_trip); } + +#[test] +fn check_egfx_round_trip() { + check!(egfx_round_trip); +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 37b519bcb8..74ba4c0f6c 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -90,3 +90,10 @@ test = false doc = false bench = false +[[bin]] +name = "egfx_round_trip" +path = "fuzz_targets/egfx_round_trip.rs" +test = false +doc = false +bench = false + diff --git a/fuzz/fuzz_targets/egfx_round_trip.rs b/fuzz/fuzz_targets/egfx_round_trip.rs new file mode 100644 index 0000000000..ae3e3f7feb --- /dev/null +++ b/fuzz/fuzz_targets/egfx_round_trip.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::egfx_round_trip(data); +}); From 059ca902a5518113163042225bc5d2088869933a Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 27 May 2026 08:54:54 -0500 Subject: [PATCH 260/325] feat(pdu,graphics,egfx): add ClearCodec bitmap compression codec (#1174) --- .../src/clearcodec/glyph_cache.rs | 98 +++ crates/ironrdp-graphics/src/clearcodec/mod.rs | 764 ++++++++++++++++++ .../src/clearcodec/vbar_cache.rs | 206 +++++ crates/ironrdp-graphics/src/lib.rs | 1 + .../src/codecs/clearcodec/bands.rs | 251 ++++++ .../ironrdp-pdu/src/codecs/clearcodec/mod.rs | 203 +++++ .../src/codecs/clearcodec/residual.rs | 199 +++++ .../ironrdp-pdu/src/codecs/clearcodec/rlex.rs | 214 +++++ .../src/codecs/clearcodec/subcodec.rs | 180 +++++ crates/ironrdp-pdu/src/codecs/mod.rs | 1 + .../tests/graphics/clearcodec.rs | 539 ++++++++++++ .../tests/graphics/mod.rs | 1 + 12 files changed, 2657 insertions(+) create mode 100644 crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs create mode 100644 crates/ironrdp-graphics/src/clearcodec/mod.rs create mode 100644 crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs create mode 100644 crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs create mode 100644 crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs create mode 100644 crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs create mode 100644 crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs create mode 100644 crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs create mode 100644 crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs diff --git a/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs b/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs new file mode 100644 index 0000000000..519d6b8108 --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs @@ -0,0 +1,98 @@ +//! Glyph cache for ClearCodec (MS-RDPEGFX 2.2.4.1). +//! +//! When a bitmap area is <= 1024 pixels, ClearCodec can index it in a +//! 4,000-entry glyph cache. On a cache hit (FLAG_GLYPH_HIT), the previously +//! cached pixel data is reused without retransmission. + +/// Maximum number of glyph cache entries. +pub const GLYPH_CACHE_SIZE: usize = 4_000; + +/// A cached glyph entry: BGRA pixel data with dimensions. +#[derive(Debug, Clone)] +pub struct GlyphEntry { + pub width: u16, + pub height: u16, + /// BGRA pixel data (4 bytes per pixel). + pub pixels: Vec, +} + +/// Glyph cache for ClearCodec bitmap deduplication. +pub struct GlyphCache { + entries: Vec>, +} + +impl GlyphCache { + pub fn new() -> Self { + let mut entries = Vec::with_capacity(GLYPH_CACHE_SIZE); + entries.resize_with(GLYPH_CACHE_SIZE, || None); + Self { entries } + } + + /// Look up a glyph by its cache index. + pub fn get(&self, index: u16) -> Option<&GlyphEntry> { + self.entries.get(usize::from(index)).and_then(|slot| slot.as_ref()) + } + + /// Store a glyph at the given index. + /// + /// Returns `true` if the index was valid and the entry was stored. + pub fn store(&mut self, index: u16, entry: GlyphEntry) -> bool { + let idx = usize::from(index); + if idx < GLYPH_CACHE_SIZE { + self.entries[idx] = Some(entry); + true + } else { + false + } + } + + /// Reset the entire glyph cache, removing all entries. + pub fn reset(&mut self) { + for slot in &mut self.entries { + *slot = None; + } + } +} + +impl Default for GlyphCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_and_retrieve() { + let mut cache = GlyphCache::new(); + let entry = GlyphEntry { + width: 8, + height: 16, + pixels: vec![0xFF; 8 * 16 * 4], + }; + assert!(cache.store(42, entry)); + let retrieved = cache.get(42).unwrap(); + assert_eq!(retrieved.width, 8); + assert_eq!(retrieved.height, 16); + } + + #[test] + fn get_empty_returns_none() { + let cache = GlyphCache::new(); + assert!(cache.get(0).is_none()); + assert!(cache.get(3999).is_none()); + } + + #[test] + fn reject_out_of_range() { + let mut cache = GlyphCache::new(); + let entry = GlyphEntry { + width: 1, + height: 1, + pixels: vec![0; 4], + }; + assert!(!cache.store(4000, entry)); + } +} diff --git a/crates/ironrdp-graphics/src/clearcodec/mod.rs b/crates/ironrdp-graphics/src/clearcodec/mod.rs new file mode 100644 index 0000000000..ab6e653c5f --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/mod.rs @@ -0,0 +1,764 @@ +//! ClearCodec bitmap decoder and encoder (MS-RDPEGFX 2.2.4.1). +//! +//! ClearCodec is a mandatory lossless codec for EGFX that uses three-layer +//! compositing (residual BGR RLE, bands with V-bar caching, subcodecs) to +//! efficiently encode text, UI elements, and icons. + +mod glyph_cache; +mod vbar_cache; + +pub use self::glyph_cache::{GLYPH_CACHE_SIZE, GlyphCache, GlyphEntry}; +pub use self::vbar_cache::{FullVBar, ShortVBar, VBarCache}; + +/// Glyph cache size as u16 for index arithmetic. GLYPH_CACHE_SIZE=4000 fits in u16. +const GLYPH_CACHE_WRAP: u16 = 4_000; + +use ironrdp_core::{DecodeResult, ReadCursor, invalid_field_err}; +use ironrdp_pdu::codecs::clearcodec::{ + ClearCodecBitmapStream, CompositePayload, FLAG_GLYPH_INDEX, RgbRunSegment, SubcodecId, VBar, decode_bands_layer, + decode_residual_layer, decode_subcodec_layer, encode_residual_layer, +}; + +/// ClearCodec decoder maintaining persistent cache state across frames. +pub struct ClearCodecDecoder { + vbar_cache: VBarCache, + glyph_cache: GlyphCache, +} + +impl ClearCodecDecoder { + pub fn new() -> Self { + Self { + vbar_cache: VBarCache::new(), + glyph_cache: GlyphCache::new(), + } + } + + /// Decode a ClearCodec bitmap stream into BGRA pixel data. + /// + /// The output buffer is `width * height * 4` bytes in BGRA format. + /// The caller is responsible for compositing the result onto the target + /// surface at the destination rectangle. + /// + /// **Alpha contract:** ClearCodec is lossless on the three color channels + /// (B, G, R) per MS-RDPEGFX 2.2.4.1. The wire format does not transmit + /// alpha; this decoder fills the alpha byte of every output pixel with + /// `0xFF` unconditionally. Callers that need to preserve alpha across the + /// network must transport it separately. + pub fn decode(&mut self, data: &[u8], width: u16, height: u16) -> DecodeResult> { + let mut src = ReadCursor::new(data); + let stream = ClearCodecBitmapStream::decode(&mut src)?; + + // Handle cache reset + if stream.is_cache_reset() { + self.vbar_cache.reset(); + } + + // Validate glyph index range per spec: 0..3999 inclusive + if let Some(idx) = stream.glyph_index { + if idx >= GLYPH_CACHE_WRAP { + return Err(invalid_field_err!("glyphIndex", "glyph index out of range 0-3999")); + } + } + + let w = usize::from(width); + let h = usize::from(height); + let pixel_count = w + .checked_mul(h) + .ok_or_else(|| invalid_field_err!("dimensions", "width * height overflow"))?; + + // Handle glyph hit: return cached pixel data + if stream.is_glyph_hit() { + let glyph_index = stream + .glyph_index + .ok_or_else(|| invalid_field_err!("flags", "GLYPH_HIT without GLYPH_INDEX"))?; + let entry = self + .glyph_cache + .get(glyph_index) + .ok_or_else(|| invalid_field_err!("glyphIndex", "glyph cache miss on hit"))?; + if entry.width != width || entry.height != height { + return Err(invalid_field_err!("glyphIndex", "cached glyph dimensions mismatch")); + } + return Ok(entry.pixels.clone()); + } + + // Cap allocation to prevent OOM from adversarial dimensions. + // MS-RDPEGFX caps surfaces at 32767x32767; the spec does not + // mandate a separate tile cap. We cap each tile dimension at + // 8192 (supports 8K displays at 7680x4320 plus headroom). The + // per-dimension form rather than a per-pixel-count form is + // important because the original pixel-count cap (8192*8192 + // = 67M) accepted degenerate aspect ratios like 63961x771 + // (49M pixels, under cap) that allocate ~197MB from a few + // attacker-controlled bytes. Capping each axis directly + // rejects implausible tile shapes regardless of total area. + const MAX_DECODE_DIM: u16 = 8192; + if width > MAX_DECODE_DIM || height > MAX_DECODE_DIM { + return Err(invalid_field_err!( + "dimensions", + "width or height exceeds 8192-pixel decoder limit" + )); + } + + // Decode composite payload + let mut output = vec![0u8; pixel_count * 4]; + + if let Some(ref composite) = stream.composite { + self.decode_composite(composite, &mut output, width, height)?; + } + + // Store in glyph cache if applicable (area <= 1024 pixels) + if stream.flags & FLAG_GLYPH_INDEX != 0 { + if let Some(glyph_index) = stream.glyph_index { + if pixel_count <= 1024 { + self.glyph_cache.store( + glyph_index, + GlyphEntry { + width, + height, + pixels: output.clone(), + }, + ); + } + } + } + + Ok(output) + } + + fn decode_composite( + &mut self, + composite: &CompositePayload<'_>, + output: &mut [u8], + width: u16, + _height: u16, + ) -> DecodeResult<()> { + let w = usize::from(width); + + // Layer 1: Residual (BGR RLE) - fills the entire output. + // Cap pixel writes to the output buffer size to prevent CPU-spin DoS + // from adversarial run_length values (FreeRDP CVE GHSA-32q9-m5qr-9j2v). + if !composite.residual_data.is_empty() { + let segments = decode_residual_layer(composite.residual_data)?; + let max_offset = output.len(); + let mut offset = 0; + for seg in &segments { + let pixels_remaining = (max_offset.saturating_sub(offset)) / 4; + let effective_run = u32::try_from(pixels_remaining).unwrap_or(u32::MAX).min(seg.run_length); + for _ in 0..effective_run { + output[offset] = seg.blue; + output[offset + 1] = seg.green; + output[offset + 2] = seg.red; + output[offset + 3] = 0xFF; // Alpha + offset += 4; + } + if offset >= max_offset { + break; + } + } + } + + // Layer 2: Bands (V-bar cached columns) - composite on top + if !composite.bands_data.is_empty() { + let bands = decode_bands_layer(composite.bands_data)?; + for band in &bands { + let band_height = band.y_end - band.y_start + 1; + for (col_offset, vbar) in band.vbars.iter().enumerate() { + let x = usize::from(band.x_start) + col_offset; + if x >= w { + continue; + } + + let full_vbar = + self.resolve_vbar(vbar, band_height, band.blue_bkg, band.green_bkg, band.red_bkg)?; + + // Blit the full V-bar column into the output + let pixel_rows = full_vbar.pixels.len() / 3; + for row in 0..pixel_rows { + let y = usize::from(band.y_start) + row; + let dst_offset = (y * w + x) * 4; + let src_offset = row * 3; + if dst_offset + 3 < output.len() && src_offset + 2 < full_vbar.pixels.len() { + output[dst_offset] = full_vbar.pixels[src_offset]; + output[dst_offset + 1] = full_vbar.pixels[src_offset + 1]; + output[dst_offset + 2] = full_vbar.pixels[src_offset + 2]; + output[dst_offset + 3] = 0xFF; + } + } + } + } + } + + // Layer 3: Subcodecs - composite on top + if !composite.subcodec_data.is_empty() { + let subcodecs = decode_subcodec_layer(composite.subcodec_data)?; + for sub in &subcodecs { + self.decode_subcodec_region(sub, output, width)?; + } + } + + Ok(()) + } + + fn resolve_vbar( + &mut self, + vbar: &VBar<'_>, + band_height: u16, + bg_blue: u8, + bg_green: u8, + bg_red: u8, + ) -> DecodeResult { + match vbar { + VBar::CacheHit { index } => { + let cached = self + .vbar_cache + .get_vbar(*index) + .ok_or_else(|| invalid_field_err!("vbarIndex", "V-bar cache miss on hit"))?; + Ok(cached.clone()) + } + VBar::ShortCacheHit { index, y_on } => { + let cached_short = self + .vbar_cache + .get_short_vbar(*index) + .ok_or_else(|| invalid_field_err!("shortVbarIndex", "short V-bar cache miss on hit"))?; + // Create a modified short vbar with the y_on from this reference + let modified = ShortVBar { + y_on: *y_on, + pixel_count: cached_short.pixel_count, + pixels: cached_short.pixels.clone(), + }; + let full = VBarCache::reconstruct_full_vbar(&modified, band_height, bg_blue, bg_green, bg_red); + // Store reconstructed full V-bar in cache + self.vbar_cache.store_vbar(full.clone()); + Ok(full) + } + VBar::ShortCacheMiss(miss) => { + let short = ShortVBar { + y_on: miss.y_on, + pixel_count: miss.y_off_delta, + pixels: miss.pixel_data.to_vec(), + }; + // Store in short V-bar cache + self.vbar_cache.store_short_vbar(short.clone()); + // Reconstruct and store full V-bar + let full = VBarCache::reconstruct_full_vbar(&short, band_height, bg_blue, bg_green, bg_red); + self.vbar_cache.store_vbar(full.clone()); + Ok(full) + } + } + } + + // NsCodec variant will use decoder state in Phase A7 + #[expect(clippy::unused_self)] + fn decode_subcodec_region( + &self, + sub: &ironrdp_pdu::codecs::clearcodec::Subcodec<'_>, + output: &mut [u8], + surface_width: u16, + ) -> DecodeResult<()> { + let sw = usize::from(surface_width); + let sh = output.len() / (sw * 4).max(1); + + let x_end = usize::from(sub.x_start) + usize::from(sub.width); + let y_end = usize::from(sub.y_start) + usize::from(sub.height); + if x_end > sw || y_end > sh { + return Err(invalid_field_err!("subcodec", "region exceeds surface bounds")); + } + + match sub.codec_id { + SubcodecId::Raw => { + let w = usize::from(sub.width); + let h = usize::from(sub.height); + let expected = w + .checked_mul(h) + .and_then(|v| v.checked_mul(3)) + .ok_or_else(|| invalid_field_err!("bitmapData", "raw subcodec dimensions overflow"))?; + if sub.bitmap_data.len() < expected { + return Err(invalid_field_err!("bitmapData", "raw subcodec data too short")); + } + for row in 0..h { + for col in 0..w { + let x = usize::from(sub.x_start) + col; + let y = usize::from(sub.y_start) + row; + let src_idx = (row * w + col) * 3; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = sub.bitmap_data[src_idx]; + output[dst_idx + 1] = sub.bitmap_data[src_idx + 1]; + output[dst_idx + 2] = sub.bitmap_data[src_idx + 2]; + output[dst_idx + 3] = 0xFF; + } + } + } + SubcodecId::Rlex => { + let rlex = ironrdp_pdu::codecs::clearcodec::decode_rlex(sub.bitmap_data)?; + let w = usize::from(sub.width); + let region_pixels = usize::from(sub.width) * usize::from(sub.height); + let palette_len = rlex.palette.len(); + let mut px = 0usize; + + for seg in &rlex.segments { + if usize::from(seg.start_index) >= palette_len { + return Err(invalid_field_err!("rlex", "start_index exceeds palette size")); + } + if usize::from(seg.stop_index) >= palette_len { + return Err(invalid_field_err!("rlex", "stop_index exceeds palette size")); + } + + let color = &rlex.palette[usize::from(seg.start_index)]; + for _ in 0..seg.run_length { + if px >= region_pixels { + return Err(invalid_field_err!("rlex", "run exceeds region pixel count")); + } + let x = usize::from(sub.x_start) + px % w; + let y = usize::from(sub.y_start) + px / w; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = color[0]; + output[dst_idx + 1] = color[1]; + output[dst_idx + 2] = color[2]; + output[dst_idx + 3] = 0xFF; + px += 1; + } + + for palette_idx in seg.start_index..=seg.stop_index { + if px >= region_pixels { + return Err(invalid_field_err!("rlex", "suite exceeds region pixel count")); + } + let color = &rlex.palette[usize::from(palette_idx)]; + let x = usize::from(sub.x_start) + px % w; + let y = usize::from(sub.y_start) + px / w; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = color[0]; + output[dst_idx + 1] = color[1]; + output[dst_idx + 2] = color[2]; + output[dst_idx + 3] = 0xFF; + px += 1; + } + } + } + SubcodecId::NsCodec => { + // Not yet implemented; encoder avoids generating NSCodec tiles. + } + } + + Ok(()) + } +} + +impl Default for ClearCodecDecoder { + fn default() -> Self { + Self::new() + } +} + +/// ClearCodec encoder for server-side bitmap compression. +/// +/// Encodes BGRA pixel data into ClearCodec bitmap streams using the residual +/// (BGR RLE) layer. The residual-only strategy gives good compression for +/// solid regions and text without requiring V-bar cache synchronization. +pub struct ClearCodecEncoder { + seq_number: u8, + glyph_cache: GlyphCache, + next_glyph_index: u16, +} + +impl ClearCodecEncoder { + pub fn new() -> Self { + Self { + seq_number: 0, + glyph_cache: GlyphCache::new(), + next_glyph_index: 0, + } + } + + /// Encode BGRA pixel data into a ClearCodec bitmap stream. + /// + /// Input: BGRA pixels in row-major order, `width * height * 4` bytes. + /// Returns the wire-format ClearCodec bitmap stream ready for + /// `WireToSurface1Pdu.bitmap_data`. + /// + /// **Alpha contract:** ClearCodec is lossless on the three color channels + /// (B, G, R) per MS-RDPEGFX 2.2.4.1. The wire format does not transmit + /// alpha; this encoder reads only B, G, R from each input pixel and + /// discards the alpha byte. Callers that need to preserve alpha across + /// the network must transport it separately. + pub fn encode(&mut self, bgra: &[u8], width: u16, height: u16) -> Vec { + let w = usize::from(width); + let h = usize::from(height); + let pixel_count = w.saturating_mul(h); + let use_glyph = pixel_count <= 1024; + + // Check glyph cache for exact match + if use_glyph { + if let Some((hit_index, _)) = self.find_glyph_match(bgra, width, height) { + return self.encode_glyph_hit(hit_index); + } + } + + // Convert BGRA to BGR run segments + let segments = bgra_to_run_segments(bgra, pixel_count); + let residual_data = encode_residual_layer(&segments); + + let mut flags = 0u8; + let glyph_index = if use_glyph { + flags |= FLAG_GLYPH_INDEX; + let idx = self.next_glyph_index; + self.glyph_cache.store( + idx, + GlyphEntry { + width, + height, + pixels: bgra.to_vec(), + }, + ); + self.next_glyph_index = (idx + 1) % GLYPH_CACHE_WRAP; + Some(idx) + } else { + None + }; + + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + + // Build the wire-format bitmap stream + let mut out = Vec::with_capacity(2 + 2 + 12 + residual_data.len()); + out.push(flags); + out.push(seq); + + if let Some(idx) = glyph_index { + out.extend_from_slice(&idx.to_le_bytes()); + } + + // Composite payload: residual only (bands=0, subcodec=0) + let residual_len = u32::try_from(residual_data.len()).unwrap_or(u32::MAX); + out.extend_from_slice(&residual_len.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // bandsByteCount + out.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount + out.extend_from_slice(&residual_data); + + out + } + + /// Encode a cache reset message (FLAG_CACHE_RESET). + pub fn encode_cache_reset(&mut self) -> Vec { + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + vec![ironrdp_pdu::codecs::clearcodec::FLAG_CACHE_RESET, seq] + } + + fn find_glyph_match(&self, bgra: &[u8], width: u16, height: u16) -> Option<(u16, &GlyphEntry)> { + // Linear scan of recently used glyph indices. + // For small cache usage this is fine; a hash index could be added later. + let search_range = GLYPH_CACHE_WRAP; + for idx in 0..search_range { + if let Some(entry) = self.glyph_cache.get(idx) { + if entry.width == width && entry.height == height && entry.pixels == bgra { + return Some((idx, entry)); + } + } + } + None + } + + fn encode_glyph_hit(&mut self, index: u16) -> Vec { + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + + let flags = FLAG_GLYPH_INDEX | ironrdp_pdu::codecs::clearcodec::FLAG_GLYPH_HIT; + let mut out = Vec::with_capacity(4); + out.push(flags); + out.push(seq); + out.extend_from_slice(&index.to_le_bytes()); + out + } +} + +impl Default for ClearCodecEncoder { + fn default() -> Self { + Self::new() + } +} + +/// Convert BGRA pixels to BGR run-length segments. +fn bgra_to_run_segments(bgra: &[u8], pixel_count: usize) -> Vec { + if pixel_count == 0 { + return Vec::new(); + } + + // Cap to the number of complete pixels actually present in the input + let available_pixels = bgra.len() / 4; + let pixel_count = pixel_count.min(available_pixels); + + let mut segments = Vec::new(); + let mut i = 0; + + while i < pixel_count { + let offset = i * 4; + if offset + 2 >= bgra.len() { + break; + } + + let blue = bgra[offset]; + let green = bgra[offset + 1]; + let red = bgra[offset + 2]; + // Alpha channel is discarded (ClearCodec is always opaque BGR) + + let mut run_length = 1u32; + let mut j = i + 1; + while j < pixel_count { + let jo = j * 4; + if jo + 2 >= bgra.len() { + break; + } + if bgra[jo] == blue && bgra[jo + 1] == green && bgra[jo + 2] == red { + run_length += 1; + j += 1; + } else { + break; + } + } + + segments.push(RgbRunSegment { + blue, + green, + red, + run_length, + }); + i = j; + } + + segments +} + +#[cfg(test)] +mod tests { + use ironrdp_pdu::codecs::clearcodec::{FLAG_CACHE_RESET, FLAG_GLYPH_HIT}; + + use super::*; + + fn make_residual_only_stream(width: u16, height: u16, blue: u8, green: u8, red: u8) -> Vec { + let pixel_count = u32::from(width) * u32::from(height); + let mut data = Vec::new(); + + // Flags=0x00 (no glyph, no cache reset), seq=0x00 + data.push(0x00); + data.push(0x00); + + // Composite payload header + // Residual: 4 bytes (1 run segment: BGR + short run) + let run_length = pixel_count; + let residual = if run_length < 0xFF { + vec![blue, green, red, u8::try_from(run_length).unwrap()] + } else if run_length < 0xFFFF { + let mut v = vec![blue, green, red, 0xFF]; + v.extend_from_slice(&u16::try_from(run_length).unwrap().to_le_bytes()); + v + } else { + let mut v = vec![blue, green, red, 0xFF, 0xFF, 0xFF]; + v.extend_from_slice(&run_length.to_le_bytes()); + v + }; + let residual_len = u32::try_from(residual.len()).unwrap(); + + data.extend_from_slice(&residual_len.to_le_bytes()); // residualByteCount + data.extend_from_slice(&0u32.to_le_bytes()); // bandsByteCount + data.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount + data.extend_from_slice(&residual); + + data + } + + #[test] + fn decode_solid_red_4x4() { + let mut decoder = ClearCodecDecoder::new(); + let stream = make_residual_only_stream(4, 4, 0x00, 0x00, 0xFF); // red in BGR + let pixels = decoder.decode(&stream, 4, 4).unwrap(); + assert_eq!(pixels.len(), 4 * 4 * 4); + // Check first pixel: BGRA + assert_eq!(pixels[0], 0x00); // B + assert_eq!(pixels[1], 0x00); // G + assert_eq!(pixels[2], 0xFF); // R + assert_eq!(pixels[3], 0xFF); // A + } + + #[test] + fn glyph_cache_round_trip() { + let mut decoder = ClearCodecDecoder::new(); + + // First decode: GLYPH_INDEX set, stores in glyph cache + let mut stream = Vec::new(); + stream.push(FLAG_GLYPH_INDEX); // flags + stream.push(0x00); // seq + stream.extend_from_slice(&42u16.to_le_bytes()); // glyph_index = 42 + // Composite with 1-pixel residual (white) + let residual = [0xFF, 0xFF, 0xFF, 0x01]; // BGR white, run=1 + stream.extend_from_slice(&4u32.to_le_bytes()); // residual bytes + stream.extend_from_slice(&0u32.to_le_bytes()); // bands bytes + stream.extend_from_slice(&0u32.to_le_bytes()); // subcodec bytes + stream.extend_from_slice(&residual); + + let pixels1 = decoder.decode(&stream, 1, 1).unwrap(); + assert_eq!(pixels1.len(), 4); + + // Second decode: GLYPH_HIT - should return cached data + let mut hit_stream = Vec::new(); + hit_stream.push(FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT); // flags + hit_stream.push(0x01); // seq = 1 + hit_stream.extend_from_slice(&42u16.to_le_bytes()); // glyph_index = 42 + + let pixels2 = decoder.decode(&hit_stream, 1, 1).unwrap(); + assert_eq!(pixels1, pixels2); + } + + #[test] + fn raw_subcodec_decode() { + let mut decoder = ClearCodecDecoder::new(); + let mut stream = Vec::new(); + stream.push(0x00); // flags + stream.push(0x00); // seq + + // Composite: no residual, no bands, 1 raw subcodec region + let mut subcodec_data = Vec::new(); + subcodec_data.extend_from_slice(&0u16.to_le_bytes()); // x_start + subcodec_data.extend_from_slice(&0u16.to_le_bytes()); // y_start + subcodec_data.extend_from_slice(&2u16.to_le_bytes()); // width + subcodec_data.extend_from_slice(&1u16.to_le_bytes()); // height + subcodec_data.extend_from_slice(&6u32.to_le_bytes()); // 2 pixels * 3 bytes + subcodec_data.push(0x00); // SubcodecId::Raw + subcodec_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // pixel 0: red + subcodec_data.extend_from_slice(&[0xFF, 0x00, 0x00]); // pixel 1: blue + + let subcodec_len = u32::try_from(subcodec_data.len()).unwrap(); + stream.extend_from_slice(&0u32.to_le_bytes()); // residual + stream.extend_from_slice(&0u32.to_le_bytes()); // bands + stream.extend_from_slice(&subcodec_len.to_le_bytes()); // subcodec + stream.extend_from_slice(&subcodec_data); + + let pixels = decoder.decode(&stream, 2, 1).unwrap(); + assert_eq!(pixels.len(), 2 * 4); // 2 pixels * BGRA + // Pixel 0: red (BGR: 0x00, 0x00, 0xFF) + assert_eq!(&pixels[0..4], &[0x00, 0x00, 0xFF, 0xFF]); + // Pixel 1: blue (BGR: 0xFF, 0x00, 0x00) + assert_eq!(&pixels[4..8], &[0xFF, 0x00, 0x00, 0xFF]); + } + + #[test] + fn cache_reset_clears_vbar_cursors() { + let mut decoder = ClearCodecDecoder::new(); + // Decode something to advance cursors, then reset + let stream = make_residual_only_stream(1, 1, 0, 0, 0); + decoder.decode(&stream, 1, 1).unwrap(); + + // Cache reset message + let reset_data = [FLAG_CACHE_RESET, 0x01]; // flags=CACHE_RESET, seq=1 + let _ = decoder.decode(&reset_data, 0, 0); // zero dimensions, but cache reset still processed + } + + // --- Encoder tests --- + + #[test] + fn encode_solid_color_round_trip() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + // 4x4 solid red (BGRA: 0,0,255,255) + let bgra: Vec = (0..16).flat_map(|_| [0x00, 0x00, 0xFF, 0xFF]).collect(); + + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + + assert_eq!(result, bgra); + } + + #[test] + fn encode_two_color_stripe_round_trip() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + // 4x1: 2 red + 2 blue pixels + let mut bgra = Vec::new(); + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); // red + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); // red + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); // blue + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); // blue + + let wire = enc.encode(&bgra, 4, 1); + let result = dec.decode(&wire, 4, 1).unwrap(); + + assert_eq!(result, bgra); + } + + #[test] + fn encode_glyph_cache_hit() { + let mut encoder = ClearCodecEncoder::new(); + + // Small 1x1 pixel (fits glyph cache: area=1 <= 1024) + let bgra = vec![0xFF, 0x00, 0x00, 0xFF]; // blue + + let first = encoder.encode(&bgra, 1, 1); + let second = encoder.encode(&bgra, 1, 1); + + // Second encode should be a glyph hit (shorter) + assert!( + second.len() < first.len(), + "glyph hit should be shorter than full encode" + ); + + // Both should decode to the same pixels + let mut decoder = ClearCodecDecoder::new(); + let p1 = decoder.decode(&first, 1, 1).unwrap(); + let p2 = decoder.decode(&second, 1, 1).unwrap(); + assert_eq!(p1, p2); + assert_eq!(p1, bgra); + } + + #[test] + fn encode_sequence_numbers_increment() { + let mut encoder = ClearCodecEncoder::new(); + let bgra = vec![0x00, 0x00, 0x00, 0xFF]; // 1x1 black + + let e1 = encoder.encode(&bgra, 1, 1); + let e2 = encoder.encode(&bgra, 1, 1); + + // Seq numbers are at byte offset 1 + // First frame starts with glyph_index flag + seq=0 + assert_eq!(e1[1], 0x00); + // Second is glyph hit: seq=1 + assert_eq!(e2[1], 0x01); + } + + #[test] + fn encode_cache_reset() { + let mut encoder = ClearCodecEncoder::new(); + let reset = encoder.encode_cache_reset(); + + let mut decoder = ClearCodecDecoder::new(); + let _ = decoder.decode(&reset, 0, 0); + // Just verifies it doesn't error + } + + #[test] + fn bgra_to_run_segments_compresses_runs() { + // 8 identical pixels should produce 1 segment with run_length=8 + let bgra: Vec = (0..8).flat_map(|_| [0xAA, 0xBB, 0xCC, 0xFF]).collect(); + let segments = bgra_to_run_segments(&bgra, 8); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].run_length, 8); + assert_eq!(segments[0].blue, 0xAA); + assert_eq!(segments[0].green, 0xBB); + assert_eq!(segments[0].red, 0xCC); + } + + #[test] + fn bgra_to_run_segments_unique_pixels() { + // 3 different pixels produce 3 segments + let bgra = vec![ + 0x01, 0x02, 0x03, 0xFF, // pixel 1 + 0x04, 0x05, 0x06, 0xFF, // pixel 2 + 0x07, 0x08, 0x09, 0xFF, // pixel 3 + ]; + let segments = bgra_to_run_segments(&bgra, 3); + assert_eq!(segments.len(), 3); + for seg in &segments { + assert_eq!(seg.run_length, 1); + } + } +} diff --git a/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs b/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs new file mode 100644 index 0000000000..37a51667a6 --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs @@ -0,0 +1,206 @@ +//! V-Bar caching for ClearCodec bands layer. +//! +//! The V-bar cache uses two ring buffers: +//! - **V-Bar Storage**: 32,768 full V-bars (complete column pixel data for a band height) +//! - **Short V-Bar Storage**: 16,384 short V-bars (only the non-background portion) +//! +//! Cache cursors advance linearly and wrap around, implementing LRU eviction +//! as specified in MS-RDPEGFX 3.3.8.1. + +use ironrdp_pdu::codecs::clearcodec::{SHORT_VBAR_CACHE_SIZE, VBAR_CACHE_SIZE}; + +// VBAR_CACHE_SIZE (32,768) and SHORT_VBAR_CACHE_SIZE (16,384) as u16 for cursor wrapping. +const VBAR_WRAP: u16 = 32_768; +const SHORT_VBAR_WRAP: u16 = 16_384; + +/// A full V-bar: column of BGR pixels for the full band height. +#[derive(Debug, Clone)] +pub struct FullVBar { + /// BGR pixel data, length = band_height * 3. + pub pixels: Vec, +} + +/// A short V-bar: only the non-background pixels within a column. +#[derive(Debug, Clone)] +pub struct ShortVBar { + /// First row index where pixel data starts. + pub y_on: u8, + /// Number of pixel rows with color data. + pub pixel_count: u8, + /// BGR pixel data, length = pixel_count * 3. + pub pixels: Vec, +} + +/// Combined V-bar cache state. +pub struct VBarCache { + /// Full V-bar storage (32,768 entries, ring buffer). + vbar_storage: Vec>, + /// Short V-bar storage (16,384 entries, ring buffer). + short_vbar_storage: Vec>, + /// Current write cursor for V-bar storage (wraps at 32767). + vbar_cursor: u16, + /// Current write cursor for short V-bar storage (wraps at 16383). + short_vbar_cursor: u16, +} + +impl VBarCache { + pub fn new() -> Self { + let mut vbar_storage = Vec::with_capacity(VBAR_CACHE_SIZE); + vbar_storage.resize_with(VBAR_CACHE_SIZE, || None); + + let mut short_vbar_storage = Vec::with_capacity(SHORT_VBAR_CACHE_SIZE); + short_vbar_storage.resize_with(SHORT_VBAR_CACHE_SIZE, || None); + + Self { + vbar_storage, + short_vbar_storage, + vbar_cursor: 0, + short_vbar_cursor: 0, + } + } + + /// Reset both caches (when FLAG_CACHE_RESET is received). + pub fn reset(&mut self) { + self.vbar_cursor = 0; + self.short_vbar_cursor = 0; + // Per spec, only cursors reset. Existing entries become stale + // but the cursor reset means new entries overwrite from index 0. + } + + /// Get a full V-bar from cache by index. + pub fn get_vbar(&self, index: u16) -> Option<&FullVBar> { + self.vbar_storage.get(usize::from(index)).and_then(|slot| slot.as_ref()) + } + + /// Get a short V-bar from cache by index. + pub fn get_short_vbar(&self, index: u16) -> Option<&ShortVBar> { + self.short_vbar_storage + .get(usize::from(index)) + .and_then(|slot| slot.as_ref()) + } + + /// Store a short V-bar and return its cache index. + pub fn store_short_vbar(&mut self, short_vbar: ShortVBar) -> u16 { + let index = self.short_vbar_cursor; + self.short_vbar_storage[usize::from(index)] = Some(short_vbar); + self.short_vbar_cursor = (index + 1) % SHORT_VBAR_WRAP; + index + } + + /// Store a full V-bar and return its cache index. + pub fn store_vbar(&mut self, vbar: FullVBar) -> u16 { + let index = self.vbar_cursor; + self.vbar_storage[usize::from(index)] = Some(vbar); + self.vbar_cursor = (index + 1) % VBAR_WRAP; + index + } + + /// Reconstruct a full V-bar from a short V-bar and background color. + /// + /// The full V-bar has: + /// - Background color above y_on + /// - Short V-bar pixel data from y_on to y_on + pixel_count + /// - Background color below y_on + pixel_count + pub fn reconstruct_full_vbar( + short_vbar: &ShortVBar, + band_height: u16, + bg_blue: u8, + bg_green: u8, + bg_red: u8, + ) -> FullVBar { + let height = usize::from(band_height); + let mut pixels = Vec::with_capacity(height * 3); + + // Background above y_on + for _ in 0..usize::from(short_vbar.y_on) { + pixels.push(bg_blue); + pixels.push(bg_green); + pixels.push(bg_red); + } + + // Pixel data from short V-bar + pixels.extend_from_slice(&short_vbar.pixels); + + // Background below y_on + pixel_count + let bottom_start = usize::from(short_vbar.y_on) + usize::from(short_vbar.pixel_count); + for _ in bottom_start..height { + pixels.push(bg_blue); + pixels.push(bg_green); + pixels.push(bg_red); + } + + FullVBar { pixels } + } +} + +impl Default for VBarCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_and_retrieve_vbar() { + let mut cache = VBarCache::new(); + let vbar = FullVBar { + pixels: vec![0xFF, 0x00, 0x00], + }; + let idx = cache.store_vbar(vbar); + assert_eq!(idx, 0); + let retrieved = cache.get_vbar(0).unwrap(); + assert_eq!(retrieved.pixels, vec![0xFF, 0x00, 0x00]); + } + + #[test] + fn cursor_wraps() { + let mut cache = VBarCache::new(); + // Store VBAR_CACHE_SIZE entries, cursor should wrap to 0 + for i in 0..VBAR_CACHE_SIZE { + let idx = cache.store_vbar(FullVBar { + pixels: vec![u8::try_from(i & 0xFF).unwrap()], + }); + assert_eq!(idx, u16::try_from(i).unwrap()); + } + // Next store should be at index 0 (wrapped) + let idx = cache.store_vbar(FullVBar { pixels: vec![0xAA] }); + assert_eq!(idx, 0); + } + + #[test] + fn reconstruct_full_vbar() { + let short = ShortVBar { + y_on: 1, + pixel_count: 2, + pixels: vec![0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00], // 2 pixels BGR + }; + let full = VBarCache::reconstruct_full_vbar(&short, 4, 0xAA, 0xBB, 0xCC); + // Height=4: 1 bg row, 2 data rows, 1 bg row + assert_eq!(full.pixels.len(), 12); // 4 * 3 + // Row 0: background + assert_eq!(&full.pixels[0..3], &[0xAA, 0xBB, 0xCC]); + // Row 1-2: pixel data + assert_eq!(&full.pixels[3..9], &[0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00]); + // Row 3: background + assert_eq!(&full.pixels[9..12], &[0xAA, 0xBB, 0xCC]); + } + + #[test] + fn reset_resets_cursors() { + let mut cache = VBarCache::new(); + cache.store_vbar(FullVBar { pixels: vec![0x01] }); + cache.store_short_vbar(ShortVBar { + y_on: 0, + pixel_count: 0, + pixels: vec![], + }); + assert_eq!(cache.vbar_cursor, 1); + assert_eq!(cache.short_vbar_cursor, 1); + cache.reset(); + assert_eq!(cache.vbar_cursor, 0); + assert_eq!(cache.short_vbar_cursor, 0); + } +} diff --git a/crates/ironrdp-graphics/src/lib.rs b/crates/ironrdp-graphics/src/lib.rs index 0e48f4119b..f375cf701b 100644 --- a/crates/ironrdp-graphics/src/lib.rs +++ b/crates/ironrdp-graphics/src/lib.rs @@ -2,6 +2,7 @@ #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] #![allow(clippy::arithmetic_side_effects)] // FIXME: remove +pub mod clearcodec; pub mod color_conversion; pub mod diff; pub mod dwt; diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs new file mode 100644 index 0000000000..863ba12241 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs @@ -0,0 +1,251 @@ +//! ClearCodec Layer 2: Bands (V-Bar Cached Columns) ([MS-RDPEGFX] 2.2.4.1.1.2). +//! +//! Bands encode rectangular strips of a bitmap using cached vertical column +//! data ("V-bars"). Each band covers a horizontal extent and contains one +//! V-bar per x-coordinate column. V-bars reference a two-level cache +//! (full V-bar storage + short V-bar storage) to exploit recurring vertical +//! column patterns typical of text glyphs. + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size, invalid_field_err}; + +/// Maximum band height per the spec. +pub const MAX_BAND_HEIGHT: u16 = 52; + +/// Number of entries in the full V-bar storage. +pub const VBAR_CACHE_SIZE: usize = 32_768; + +/// Number of entries in the short V-bar storage. +pub const SHORT_VBAR_CACHE_SIZE: usize = 16_384; + +/// A decoded band structure. +#[derive(Debug, Clone)] +pub struct Band<'a> { + pub x_start: u16, + pub x_end: u16, + pub y_start: u16, + pub y_end: u16, + /// Background color (BGR). + pub blue_bkg: u8, + pub green_bkg: u8, + pub red_bkg: u8, + /// One V-bar per column from x_start to x_end (inclusive). + pub vbars: Vec>, +} + +impl Band<'_> { + const NAME: &'static str = "ClearCodecBand"; + /// Band header: 4 x u16 + 3 x u8 = 11 bytes. + const HEADER_SIZE: usize = 11; +} + +/// A V-bar reference within a band. +/// +/// Discriminated by the top 2 bits of the first u16 word: +/// - `1x` (bit 15 set): full V-bar cache hit (15-bit index) +/// - `01` (bits 15:14 = 01): short V-bar cache hit (14-bit index + yOn offset) +/// - `00` (bits 15:14 = 00): short V-bar cache miss (inline pixel data) +#[derive(Debug, Clone)] +pub enum VBar<'a> { + /// Full V-bar cache hit. Index into V-Bar Storage (0..32767). + CacheHit { index: u16 }, + /// Short V-bar cache hit. Index into Short V-Bar Storage (0..16383) + /// plus a `yOn` offset byte for vertical positioning. + ShortCacheHit { index: u16, y_on: u8 }, + /// Short V-bar cache miss. Contains inline pixel data. + ShortCacheMiss(ShortVBarCacheMiss<'a>), +} + +/// Inline short V-bar data from a cache miss. +#[derive(Debug, Clone)] +pub struct ShortVBarCacheMiss<'a> { + /// First pixel row within the band where color data starts (shortVBarYOn). + pub y_on: u8, + /// Number of pixel rows with color data (`shortVBarYOff - shortVBarYOn`). + pub y_off_delta: u8, + /// Raw BGR pixel data: `y_off_delta * 3` bytes. + pub pixel_data: &'a [u8], +} + +/// Decode all bands from the bands layer data. +pub fn decode_bands_layer<'a>(data: &'a [u8]) -> DecodeResult>> { + let mut bands = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= Band::HEADER_SIZE { + let band = decode_single_band(&mut src)?; + bands.push(band); + } + + Ok(bands) +} + +fn decode_single_band<'a>(src: &mut ReadCursor<'a>) -> DecodeResult> { + ensure_size!(ctx: Band::NAME, in: src, size: Band::HEADER_SIZE); + + let x_start = src.read_u16(); + let x_end = src.read_u16(); + let y_start = src.read_u16(); + let y_end = src.read_u16(); + let blue_bkg = src.read_u8(); + let green_bkg = src.read_u8(); + let red_bkg = src.read_u8(); + + // Validate band height + let height = y_end + .checked_sub(y_start) + .and_then(|h| h.checked_add(1)) + .ok_or_else(|| invalid_field_err!("yEnd", "yEnd < yStart"))?; + + if height > MAX_BAND_HEIGHT { + return Err(invalid_field_err!("bandHeight", "band height exceeds 52")); + } + + if x_end < x_start { + return Err(invalid_field_err!("xEnd", "xEnd < xStart")); + } + + // `x_end - x_start` is at most u16::MAX (when x_end = u16::MAX and + // x_start = 0), so the `+ 1` would overflow u16. Cast to usize first. + let column_count = usize::from(x_end - x_start) + 1; + let mut vbars = Vec::with_capacity(column_count); + + for _ in 0..column_count { + let vbar = decode_vbar(src, height)?; + vbars.push(vbar); + } + + Ok(Band { + x_start, + x_end, + y_start, + y_end, + blue_bkg, + green_bkg, + red_bkg, + vbars, + }) +} + +fn decode_vbar<'a>(src: &mut ReadCursor<'a>, band_height: u16) -> DecodeResult> { + ensure_size!(ctx: "VBar", in: src, size: 2); + let first_word = src.read_u16(); + + // Top bit set: full V-bar cache hit + if first_word & 0x8000 != 0 { + let index = first_word & 0x7FFF; + return Ok(VBar::CacheHit { index }); + } + + // Bit 14 set (bit 15 clear): short V-bar cache hit + if first_word & 0x4000 != 0 { + let index = first_word & 0x3FFF; + ensure_size!(ctx: "ShortVBarCacheHit", in: src, size: 1); + let y_on = src.read_u8(); + return Ok(VBar::ShortCacheHit { index, y_on }); + } + + // Both top bits clear: short V-bar cache miss + // Per MS-RDPEGFX 2.2.4.1.1.2.1.1.3 (SHORT_VBAR_CACHE_MISS): + // bits 13:6 = shortVBarYOn (8 bits): row where Short V-Bar begins + // bits 5:0 = shortVBarYOff (6 bits): row where Short V-Bar ends + // Pixel count = shortVBarYOff - shortVBarYOn + let y_on = u8::try_from(first_word >> 6).expect("top 2 bits are clear, so shifted value fits in u8"); + let y_off = u8::try_from(first_word & 0x3F).expect("masked to 6 bits, always fits in u8"); + + if y_off < y_on { + return Err(invalid_field_err!("shortVBarCacheMiss", "shortVBarYOff < shortVBarYOn")); + } + + if u16::from(y_off) > band_height { + return Err(invalid_field_err!( + "shortVBarCacheMiss", + "shortVBarYOff exceeds band height" + )); + } + + let pixel_count = y_off - y_on; + let pixel_byte_count = usize::from(pixel_count) * 3; + ensure_size!(ctx: "ShortVBarCacheMiss", in: src, size: pixel_byte_count); + let pixel_data = src.read_slice(pixel_byte_count); + + Ok(VBar::ShortCacheMiss(ShortVBarCacheMiss { + y_on, + y_off_delta: pixel_count, + pixel_data, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_vbar_cache_hit() { + // Bit 15 set, index = 42 + let data = (0x8000u16 | 42).to_le_bytes(); + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::CacheHit { index } => assert_eq!(index, 42), + _ => panic!("expected CacheHit"), + } + } + + #[test] + fn decode_vbar_short_cache_hit() { + // Bit 14 set, bit 15 clear, index = 100, yOn = 5 + let mut data = Vec::new(); + data.extend_from_slice(&(0x4000u16 | 100).to_le_bytes()); + data.push(5); // yOn + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::ShortCacheHit { index, y_on } => { + assert_eq!(index, 100); + assert_eq!(y_on, 5); + } + _ => panic!("expected ShortCacheHit"), + } + } + + #[test] + fn decode_vbar_short_cache_miss() { + // Both top bits clear: y_on=2, y_off=5, pixel_count = y_off - y_on = 3 + let y_on: u16 = 2; + let y_off: u16 = 5; + let first_word = (y_on << 6) | y_off; + let mut data = Vec::new(); + data.extend_from_slice(&first_word.to_le_bytes()); + // 3 pixels * 3 bytes = 9 bytes BGR data + data.extend_from_slice(&[0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF]); + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::ShortCacheMiss(miss) => { + assert_eq!(miss.y_on, 2); + assert_eq!(miss.y_off_delta, 3); // pixel_count = y_off - y_on = 5 - 2 = 3 + assert_eq!(miss.pixel_data.len(), 9); + } + _ => panic!("expected ShortCacheMiss"), + } + } + + #[test] + fn decode_band_validates_height() { + // Band with height > 52 should fail + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); // x_start + data.extend_from_slice(&0u16.to_le_bytes()); // x_end = 0 (1 column) + data.extend_from_slice(&0u16.to_le_bytes()); // y_start + data.extend_from_slice(&52u16.to_le_bytes()); // y_end = 52, height = 53 > MAX + data.extend_from_slice(&[0, 0, 0]); // bkg BGR + let result = decode_bands_layer(&data); + assert!(result.is_err()); + } + + #[test] + fn decode_empty_bands_layer() { + let bands = decode_bands_layer(&[]).unwrap(); + assert!(bands.is_empty()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs new file mode 100644 index 0000000000..a94e61a9b2 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs @@ -0,0 +1,203 @@ +//! ClearCodec bitmap compression codec (MS-RDPEGFX 2.2.4.1). +//! +//! ClearCodec is a mandatory lossless codec for all EGFX versions (V8-V10.7). +//! It uses a three-layer composite architecture: residual (BGR RLE), bands +//! (V-bar cached columns), and subcodec (raw / NSCodec / RLEX). +//! +//! The codec is transported inside `WireToSurface1Pdu` with `codecId = 0x0008`. + +mod bands; +mod residual; +mod rlex; +mod subcodec; + +use ironrdp_core::{DecodeResult, ReadCursor, cast_length, ensure_size, invalid_field_err}; + +pub use self::bands::{ + Band, MAX_BAND_HEIGHT, SHORT_VBAR_CACHE_SIZE, ShortVBarCacheMiss, VBAR_CACHE_SIZE, VBar, decode_bands_layer, +}; +pub use self::residual::{RgbRunSegment, decode_residual_layer, encode_residual_layer}; +pub use self::rlex::{MAX_PALETTE_COUNT, RlexData, RlexSegment, decode_rlex}; +pub use self::subcodec::{Subcodec, SubcodecId, decode_subcodec_layer}; + +// --- Flag constants --- + +/// `glyphIndex` field is present (bitmap area <= 1024 pixels). +pub const FLAG_GLYPH_INDEX: u8 = 0x01; +/// Use cached glyph at `glyphIndex`; no composite payload follows. +pub const FLAG_GLYPH_HIT: u8 = 0x02; +/// Reset V-Bar and Short V-Bar storage cursors to 0. +pub const FLAG_CACHE_RESET: u8 = 0x04; + +// --- Top-level bitmap stream --- + +/// Decoded ClearCodec bitmap stream ([MS-RDPEGFX] 2.2.4.1). +#[derive(Debug, Clone)] +pub struct ClearCodecBitmapStream<'a> { + /// Combination of `FLAG_GLYPH_INDEX`, `FLAG_GLYPH_HIT`, `FLAG_CACHE_RESET`. + pub flags: u8, + /// Sequence number (wraps 0xFF -> 0x00). + pub seq_number: u8, + /// Glyph cache index, present when `FLAG_GLYPH_INDEX` is set. + pub glyph_index: Option, + /// Composite payload (three layers), absent when `FLAG_GLYPH_HIT` is set. + pub composite: Option>, +} + +impl<'a> ClearCodecBitmapStream<'a> { + const NAME: &'static str = "ClearCodecBitmapStream"; + + /// Decode the complete bitmap stream from raw bytes. + pub fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: 2); + let flags = src.read_u8(); + let seq_number = src.read_u8(); + + let glyph_index = if flags & FLAG_GLYPH_INDEX != 0 { + ensure_size!(ctx: Self::NAME, in: src, size: 2); + Some(src.read_u16()) + } else { + None + }; + + // GLYPH_HIT means use cached glyph; no payload follows. + let composite = if flags & FLAG_GLYPH_HIT != 0 { + None + } else if src.is_empty() { + // No composite payload (valid for cache reset only messages) + None + } else { + Some(CompositePayload::decode(src)?) + }; + + Ok(Self { + flags, + seq_number, + glyph_index, + composite, + }) + } + + pub fn has_glyph_index(&self) -> bool { + self.flags & FLAG_GLYPH_INDEX != 0 + } + + pub fn is_glyph_hit(&self) -> bool { + self.flags & FLAG_GLYPH_HIT != 0 + } + + pub fn is_cache_reset(&self) -> bool { + self.flags & FLAG_CACHE_RESET != 0 + } +} + +// --- Composite payload (3 layers) --- + +/// The three-layer composite payload ([MS-RDPEGFX] 2.2.4.1.1). +/// +/// Layers are applied in order: residual -> bands -> subcodec. +/// Each layer composites on top of the previous result. +#[derive(Debug, Clone)] +pub struct CompositePayload<'a> { + /// Raw bytes for the residual (BGR RLE) layer. + pub residual_data: &'a [u8], + /// Raw bytes for the bands (V-bar cached columns) layer. + pub bands_data: &'a [u8], + /// Raw bytes for the subcodec layer. + pub subcodec_data: &'a [u8], +} + +impl<'a> CompositePayload<'a> { + const NAME: &'static str = "CompositePayload"; + + /// Header: 3 x u32 byte counts. + const HEADER_SIZE: usize = 12; + + pub fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: Self::HEADER_SIZE); + + let residual_byte_count: usize = cast_length!("residualByteCount", src.read_u32())?; + let bands_byte_count: usize = cast_length!("bandsByteCount", src.read_u32())?; + let subcodec_byte_count: usize = cast_length!("subcodecByteCount", src.read_u32())?; + + let total = residual_byte_count + .checked_add(bands_byte_count) + .and_then(|s| s.checked_add(subcodec_byte_count)) + .ok_or_else(|| invalid_field_err!("byteCount", "layer byte counts overflow"))?; + + ensure_size!(ctx: Self::NAME, in: src, size: total); + + let residual_data = src.read_slice(residual_byte_count); + let bands_data = src.read_slice(bands_byte_count); + let subcodec_data = src.read_slice(subcodec_byte_count); + + Ok(Self { + residual_data, + bands_data, + subcodec_data, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_glyph_hit() { + // flags=0x03 (GLYPH_INDEX | GLYPH_HIT), seq=0x05, glyphIndex=0x0042 + let data = [0x03, 0x05, 0x42, 0x00]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + assert!(stream.has_glyph_index()); + assert!(stream.is_glyph_hit()); + assert!(!stream.is_cache_reset()); + assert_eq!(stream.seq_number, 5); + assert_eq!(stream.glyph_index, Some(0x0042)); + assert!(stream.composite.is_none()); + } + + #[test] + fn decode_cache_reset_only() { + // flags=0x04 (CACHE_RESET), seq=0x00, no glyph, no composite + let data = [0x04, 0x00]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + assert!(stream.is_cache_reset()); + assert!(!stream.has_glyph_index()); + assert!(stream.composite.is_none()); + } + + #[test] + fn decode_composite_payload_empty_layers() { + // flags=0x00, seq=0x01, composite with all-zero byte counts + let data = [ + 0x00, 0x01, // flags, seq + 0x00, 0x00, 0x00, 0x00, // residualByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // bandsByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // subcodecByteCount = 0 + ]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + let composite = stream.composite.unwrap(); + assert!(composite.residual_data.is_empty()); + assert!(composite.bands_data.is_empty()); + assert!(composite.subcodec_data.is_empty()); + } + + #[test] + fn decode_composite_with_residual_data() { + // flags=0x00, seq=0x02, residual=4 bytes, bands=0, subcodec=0 + let data = [ + 0x00, 0x02, // flags, seq + 0x04, 0x00, 0x00, 0x00, // residualByteCount = 4 + 0x00, 0x00, 0x00, 0x00, // bandsByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // subcodecByteCount = 0 + 0xFF, 0x00, 0x00, 0x01, // 4 bytes of residual data + ]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + let composite = stream.composite.unwrap(); + assert_eq!(composite.residual_data, &[0xFF, 0x00, 0x00, 0x01]); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs new file mode 100644 index 0000000000..5fdca05631 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs @@ -0,0 +1,199 @@ +//! ClearCodec Layer 1: Residual (BGR RLE) ([MS-RDPEGFX] 2.2.4.1.1.1). +//! +//! The residual layer encodes the background of the bitmap as a sequence of +//! run-length-encoded BGR pixel runs. This forms the base layer onto which +//! bands and subcodec regions are composited. + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size}; + +/// A single BGR run-length segment. +/// +/// The run length uses a variable-length encoding: +/// - `factor1 < 0xFF`: run = factor1 +/// - `factor1 == 0xFF && factor2 < 0xFFFF`: run = factor2 +/// - `factor1 == 0xFF && factor2 == 0xFFFF`: run = factor3 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RgbRunSegment { + pub blue: u8, + pub green: u8, + pub red: u8, + pub run_length: u32, +} + +impl RgbRunSegment { + const NAME: &'static str = "RgbRunSegment"; + + /// Minimum segment size: 3 bytes color + 1 byte factor1. + const MIN_SIZE: usize = 4; +} + +/// Decode all residual run segments from the residual layer data. +/// +/// Returns the sequence of run segments. The caller is responsible for +/// expanding them into a pixel buffer of `width * height` pixels. +pub fn decode_residual_layer(data: &[u8]) -> DecodeResult> { + let mut segments = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= RgbRunSegment::MIN_SIZE { + let blue = src.read_u8(); + let green = src.read_u8(); + let red = src.read_u8(); + let factor1 = src.read_u8(); + + let run_length = if factor1 < 0xFF { + u32::from(factor1) + } else { + ensure_size!(ctx: RgbRunSegment::NAME, in: src, size: 2); + let factor2 = src.read_u16(); + if factor2 < 0xFFFF { + u32::from(factor2) + } else { + ensure_size!(ctx: RgbRunSegment::NAME, in: src, size: 4); + src.read_u32() + } + }; + + segments.push(RgbRunSegment { + blue, + green, + red, + run_length, + }); + } + + Ok(segments) +} + +/// Encode residual layer data from a sequence of BGR run segments. +/// +/// Writes the variable-length encoded run segments into a Vec. +/// +/// # Panics +/// +/// Cannot panic. Internal `expect()` calls are guarded by range checks. +pub fn encode_residual_layer(segments: &[RgbRunSegment]) -> Vec { + let mut buf = Vec::with_capacity(segments.len() * 4); + + for seg in segments { + buf.push(seg.blue); + buf.push(seg.green); + buf.push(seg.red); + + if seg.run_length < 0xFF { + buf.push(u8::try_from(seg.run_length).expect("guarded by < 0xFF check")); + } else if seg.run_length < 0xFFFF { + buf.push(0xFF); + buf.extend_from_slice( + &u16::try_from(seg.run_length) + .expect("guarded by < 0xFFFF check") + .to_le_bytes(), + ); + } else { + buf.push(0xFF); + buf.extend_from_slice(&0xFFFFu16.to_le_bytes()); + buf.extend_from_slice(&seg.run_length.to_le_bytes()); + } + } + + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_single_short_run() { + // Blue=0x10, Green=0x20, Red=0x30, run=5 + let data = [0x10, 0x20, 0x30, 0x05]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments.len(), 1); + assert_eq!( + segments[0], + RgbRunSegment { + blue: 0x10, + green: 0x20, + red: 0x30, + run_length: 5 + } + ); + } + + #[test] + fn decode_medium_run() { + // run_length = 300 (0x012C), needs factor2 + let data = [0x00, 0x00, 0x00, 0xFF, 0x2C, 0x01]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments[0].run_length, 300); + } + + #[test] + fn decode_long_run() { + // run_length = 70000 (0x00011170), needs factor3 + let data = [0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x70, 0x11, 0x01, 0x00]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments[0].run_length, 70000); + } + + #[test] + fn decode_multiple_segments() { + // Two short runs + let data = [ + 0xFF, 0x00, 0x00, 0x03, // blue pixel, run=3 + 0x00, 0xFF, 0x00, 0x02, // green pixel, run=2 + ]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].run_length, 3); + assert_eq!(segments[1].run_length, 2); + } + + #[test] + fn round_trip_short() { + let original = vec![ + RgbRunSegment { + blue: 0xAA, + green: 0xBB, + red: 0xCC, + run_length: 42, + }, + RgbRunSegment { + blue: 0x00, + green: 0x00, + red: 0x00, + run_length: 0, + }, + ]; + let encoded = encode_residual_layer(&original); + let decoded = decode_residual_layer(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn round_trip_all_sizes() { + let original = vec![ + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 100, + }, // short + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 1000, + }, // medium + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 100_000, + }, // long + ]; + let encoded = encode_residual_layer(&original); + let decoded = decode_residual_layer(&encoded).unwrap(); + assert_eq!(decoded, original); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs new file mode 100644 index 0000000000..c2a5d77017 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs @@ -0,0 +1,214 @@ +//! ClearCodec RLEX subcodec ([MS-RDPEGFX] 2.2.4.1.1.3.1.3). +//! +//! RLEX is a palette-indexed RLE codec with gradient "suite" encoding. +//! It encodes each pixel as a pair: a "run" of repeated color followed +//! by a "suite" (sequential palette walk from startIndex to stopIndex). + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size, invalid_field_err}; + +/// Maximum palette size per spec. +pub const MAX_PALETTE_COUNT: u8 = 127; + +/// A decoded RLEX segment (run + suite). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RlexSegment { + /// Palette index to repeat for the run portion. + pub start_index: u8, + /// Last palette index in the suite walk. + pub stop_index: u8, + /// Number of pixels in the run (repeated start color). + pub run_length: u32, +} + +/// Decoded RLEX data: palette + segments. +#[derive(Debug, Clone)] +pub struct RlexData { + /// BGR palette entries (3 bytes each). + pub palette: Vec<[u8; 3]>, + /// Sequence of run+suite segments. + pub segments: Vec, +} + +/// Decode RLEX subcodec data. +/// +/// The data format: +/// ```text +/// paletteCount(u8) | paletteEntries[paletteCount * 3 bytes BGR] +/// segments[]: packed bit fields +/// ``` +/// +/// Bit widths derived from palette count: +/// - `stop_index_bits = floor(log2(palette_count - 1)) + 1` +/// - `suite_depth_bits = 8 - stop_index_bits` +pub fn decode_rlex(data: &[u8]) -> DecodeResult { + let mut src = ReadCursor::new(data); + + ensure_size!(ctx: "RlexPalette", in: src, size: 1); + let palette_count = src.read_u8(); + + if palette_count == 0 { + return Err(invalid_field_err!("paletteCount", "palette count is 0")); + } + + if palette_count > MAX_PALETTE_COUNT { + return Err(invalid_field_err!("paletteCount", "palette count exceeds 127")); + } + + let palette_byte_count = usize::from(palette_count) * 3; + ensure_size!(ctx: "RlexPalette", in: src, size: palette_byte_count); + + let mut palette = Vec::with_capacity(usize::from(palette_count)); + for _ in 0..palette_count { + let b = src.read_u8(); + let g = src.read_u8(); + let r = src.read_u8(); + palette.push([b, g, r]); + } + + // Compute bit widths + let stop_index_bits = if palette_count <= 1 { + // Edge case: only 1 palette entry + 0 + } else { + bit_length(u32::from(palette_count - 1)) + }; + let suite_depth_bits = 8u8.saturating_sub(stop_index_bits); + + // Decode segments from remaining bytes + let mut segments = Vec::new(); + let remaining = src.len(); + + if stop_index_bits == 0 { + // Single palette entry: no stop/suite bits, only run lengths + // Each byte is a run length factor for palette[0] + decode_single_palette_segments(&mut src, &mut segments)?; + } else { + decode_multi_palette_segments(remaining, &mut src, stop_index_bits, suite_depth_bits, &mut segments)?; + } + + Ok(RlexData { palette, segments }) +} + +fn decode_single_palette_segments(src: &mut ReadCursor<'_>, segments: &mut Vec) -> DecodeResult<()> { + while !src.is_empty() { + let run_length = decode_run_length(src)?; + segments.push(RlexSegment { + start_index: 0, + stop_index: 0, + run_length, + }); + } + Ok(()) +} + +fn decode_multi_palette_segments( + _remaining: usize, + src: &mut ReadCursor<'_>, + stop_index_bits: u8, + suite_depth_bits: u8, + segments: &mut Vec, +) -> DecodeResult<()> { + let stop_mask = (1u8 << stop_index_bits) - 1; + let depth_mask = (1u8 << suite_depth_bits) - 1; + + while !src.is_empty() { + let packed = src.read_u8(); + let stop_index = packed & stop_mask; + let suite_depth = (packed >> stop_index_bits) & depth_mask; + + let start_index = stop_index.saturating_sub(suite_depth); + + let run_length = decode_run_length(src)?; + + segments.push(RlexSegment { + start_index, + stop_index, + run_length, + }); + } + + Ok(()) +} + +/// Decode a variable-length run length value. +/// Uses the same variable-length scheme as the residual layer. +fn decode_run_length(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(ctx: "RlexRunLength", in: src, size: 1); + let factor1 = src.read_u8(); + + if factor1 < 0xFF { + return Ok(u32::from(factor1)); + } + + ensure_size!(ctx: "RlexRunLength", in: src, size: 2); + let factor2 = src.read_u16(); + + if factor2 < 0xFFFF { + return Ok(u32::from(factor2)); + } + + ensure_size!(ctx: "RlexRunLength", in: src, size: 4); + Ok(src.read_u32()) +} + +/// Compute the number of bits needed to represent a value (floor(log2(n)) + 1). +fn bit_length(n: u32) -> u8 { + if n == 0 { + return 0; + } + // Result is 1..=32 for non-zero n, always fits in u8 + u8::try_from(32 - n.leading_zeros()).expect("bit length of u32 always fits in u8") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bit_length_values() { + assert_eq!(bit_length(0), 0); + assert_eq!(bit_length(1), 1); + assert_eq!(bit_length(2), 2); + assert_eq!(bit_length(3), 2); + assert_eq!(bit_length(4), 3); + assert_eq!(bit_length(7), 3); + assert_eq!(bit_length(126), 7); + } + + #[test] + fn decode_rlex_two_palette() { + // palette_count=2, palette=[black, white] + // stop_index_bits = bit_length(1) = 1 + // suite_depth_bits = 8 - 1 = 7 + let mut data = Vec::new(); + data.push(2); // palette_count + data.extend_from_slice(&[0x00, 0x00, 0x00]); // black BGR + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // white BGR + // Segment: packed byte, stop_index=0 (1 bit), suite_depth=0 (7 bits), run=5 + data.push(0x00); // packed: stop=0, depth=0 + data.push(5); // run_length=5 + // Segment: stop_index=1, suite_depth=0, run=3 + data.push(0x01); // packed: stop=1, depth=0 + data.push(3); // run_length=3 + + let rlex = decode_rlex(&data).unwrap(); + assert_eq!(rlex.palette.len(), 2); + assert_eq!(rlex.segments.len(), 2); + assert_eq!(rlex.segments[0].stop_index, 0); + assert_eq!(rlex.segments[0].run_length, 5); + assert_eq!(rlex.segments[1].stop_index, 1); + assert_eq!(rlex.segments[1].run_length, 3); + } + + #[test] + fn reject_zero_palette() { + let data = [0x00]; // palette_count = 0 + assert!(decode_rlex(&data).is_err()); + } + + #[test] + fn reject_too_large_palette() { + let data = [128]; // palette_count = 128 > 127 + assert!(decode_rlex(&data).is_err()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs new file mode 100644 index 0000000000..fcad5eeff5 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs @@ -0,0 +1,180 @@ +//! ClearCodec Layer 3: Subcodecs ([MS-RDPEGFX] 2.2.4.1.1.3). +//! +//! The subcodec layer encodes rectangular regions using one of three methods: +//! raw BGR pixels, NSCodec, or RLEX. Each subcodec region specifies its +//! position, dimensions, and the codec used to compress its bitmap data. + +use ironrdp_core::{DecodeResult, ReadCursor, cast_length, ensure_size, invalid_field_err}; + +/// Subcodec identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum SubcodecId { + /// Uncompressed BGR pixels. + Raw = 0x00, + /// NSCodec bitmap compression (MS-RDPNSC). + NsCodec = 0x01, + /// Palette-indexed RLE with gradient suite encoding. + Rlex = 0x02, +} + +impl SubcodecId { + fn from_u8(val: u8) -> DecodeResult { + match val { + 0x00 => Ok(Self::Raw), + 0x01 => Ok(Self::NsCodec), + 0x02 => Ok(Self::Rlex), + _ => Err(invalid_field_err!("subCodecId", "unknown subcodec ID")), + } + } +} + +/// A decoded subcodec region. +#[derive(Debug, Clone)] +pub struct Subcodec<'a> { + pub x_start: u16, + pub y_start: u16, + pub width: u16, + pub height: u16, + pub codec_id: SubcodecId, + /// Raw bitmap data for this region, interpreted according to `codec_id`. + pub bitmap_data: &'a [u8], +} + +impl Subcodec<'_> { + const NAME: &'static str = "ClearCodecSubcodec"; + + /// Header: 4 x u16 + u32 + u8 = 13 bytes. + const HEADER_SIZE: usize = 13; +} + +/// Decode all subcodec regions from the subcodec layer data. +pub fn decode_subcodec_layer<'a>(data: &'a [u8]) -> DecodeResult>> { + let mut regions = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= Subcodec::HEADER_SIZE { + let region = decode_single_subcodec(&mut src)?; + regions.push(region); + } + + Ok(regions) +} + +fn decode_single_subcodec<'a>(src: &mut ReadCursor<'a>) -> DecodeResult> { + ensure_size!(ctx: Subcodec::NAME, in: src, size: Subcodec::HEADER_SIZE); + + let x_start = src.read_u16(); + let y_start = src.read_u16(); + let width = src.read_u16(); + let height = src.read_u16(); + let bitmap_data_byte_count: usize = cast_length!("bitmapDataByteCount", src.read_u32())?; + let codec_id_raw = src.read_u8(); + let codec_id = SubcodecId::from_u8(codec_id_raw)?; + + if width == 0 || height == 0 { + return Err(invalid_field_err!("dimensions", "subcodec region has zero dimension")); + } + + ensure_size!(ctx: Subcodec::NAME, in: src, size: bitmap_data_byte_count); + let bitmap_data = src.read_slice(bitmap_data_byte_count); + + Ok(Subcodec { + x_start, + y_start, + width, + height, + codec_id, + bitmap_data, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_raw_subcodec() { + // Region at (10, 20), 2x2 pixels, raw BGR = 12 bytes + let mut data = Vec::new(); + data.extend_from_slice(&10u16.to_le_bytes()); // x_start + data.extend_from_slice(&20u16.to_le_bytes()); // y_start + data.extend_from_slice(&2u16.to_le_bytes()); // width + data.extend_from_slice(&2u16.to_le_bytes()); // height + data.extend_from_slice(&12u32.to_le_bytes()); // bitmapDataByteCount = 2*2*3 = 12 + data.push(0x00); // subCodecId = Raw + // 4 pixels BGR + data.extend_from_slice(&[0xFF, 0x00, 0x00]); // blue + data.extend_from_slice(&[0x00, 0xFF, 0x00]); // green + data.extend_from_slice(&[0x00, 0x00, 0xFF]); // red + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // white + + let regions = decode_subcodec_layer(&data).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].x_start, 10); + assert_eq!(regions[0].y_start, 20); + assert_eq!(regions[0].width, 2); + assert_eq!(regions[0].height, 2); + assert_eq!(regions[0].codec_id, SubcodecId::Raw); + assert_eq!(regions[0].bitmap_data.len(), 12); + } + + #[test] + fn reject_zero_dimensions() { + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); // x_start + data.extend_from_slice(&0u16.to_le_bytes()); // y_start + data.extend_from_slice(&0u16.to_le_bytes()); // width = 0 (invalid) + data.extend_from_slice(&1u16.to_le_bytes()); // height + data.extend_from_slice(&0u32.to_le_bytes()); // bitmapDataByteCount + data.push(0x00); // subCodecId + assert!(decode_subcodec_layer(&data).is_err()); + } + + #[test] + fn reject_unknown_subcodec() { + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); + data.push(0x03); // unknown subcodec + assert!(decode_subcodec_layer(&data).is_err()); + } + + #[test] + fn decode_multiple_subcodecs() { + let mut data = Vec::new(); + // First region: 1x1 raw + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&3u32.to_le_bytes()); + data.push(0x00); // Raw + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); + + // Second region: 1x1 RLEX (minimal: palette_count=1 + run) + data.extend_from_slice(&5u16.to_le_bytes()); + data.extend_from_slice(&5u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&5u32.to_le_bytes()); + data.push(0x02); // RLEX + data.push(1); // palette_count + data.extend_from_slice(&[0x00, 0x00, 0x00]); // palette entry + data.push(1); // run_length + + let regions = decode_subcodec_layer(&data).unwrap(); + assert_eq!(regions.len(), 2); + assert_eq!(regions[0].codec_id, SubcodecId::Raw); + assert_eq!(regions[1].codec_id, SubcodecId::Rlex); + } + + #[test] + fn decode_empty_layer() { + let regions = decode_subcodec_layer(&[]).unwrap(); + assert!(regions.is_empty()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/mod.rs b/crates/ironrdp-pdu/src/codecs/mod.rs index 6fb4906b54..df6e592c6f 100644 --- a/crates/ironrdp-pdu/src/codecs/mod.rs +++ b/crates/ironrdp-pdu/src/codecs/mod.rs @@ -1 +1,2 @@ +pub mod clearcodec; pub mod rfx; diff --git a/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs b/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs new file mode 100644 index 0000000000..a0275083b2 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs @@ -0,0 +1,539 @@ +use ironrdp_core::ReadCursor; +use ironrdp_graphics::clearcodec::{ClearCodecDecoder, ClearCodecEncoder}; +use ironrdp_pdu::codecs::clearcodec::{ + ClearCodecBitmapStream, FLAG_CACHE_RESET, FLAG_GLYPH_HIT, FLAG_GLYPH_INDEX, RgbRunSegment, encode_residual_layer, +}; + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Build a residual-only ClearCodec stream (no bands, no subcodec). +fn make_residual_stream(seq: u8, flags: u8, glyph_index: Option, residual: &[u8]) -> Vec { + let mut data = Vec::new(); + data.push(flags); + data.push(seq); + if let Some(idx) = glyph_index { + data.extend_from_slice(&idx.to_le_bytes()); + } + let residual_len = u32::try_from(residual.len()).unwrap(); + data.extend_from_slice(&residual_len.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); // bands + data.extend_from_slice(&0u32.to_le_bytes()); // subcodec + data.extend_from_slice(residual); + data +} + +/// Build a solid-color residual payload for width*height pixels. +fn make_solid_residual(b: u8, g: u8, r: u8, pixel_count: u32) -> Vec { + encode_residual_layer(&[RgbRunSegment { + blue: b, + green: g, + red: r, + run_length: pixel_count, + }]) +} + +/// Build BGRA pixel data for a solid color. +fn solid_bgra(b: u8, g: u8, r: u8, pixel_count: usize) -> Vec { + (0..pixel_count).flat_map(|_| [b, g, r, 0xFF]).collect() +} + +// ============================================================================ +// Codec Round-Trip (encode -> decode, pixel-perfect) +// ============================================================================ + +#[test] +fn round_trip_1x1_single_pixel() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x00, 0x00, 0x00, 1); + let wire = enc.encode(&bgra, 1, 1); + let result = dec.decode(&wire, 1, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_4x4_solid_color() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x00, 0x00, 0xFF, 16); + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_checkerboard_alternating_pixels() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let mut bgra = Vec::with_capacity(16 * 4); + for i in 0..16 { + if i % 2 == 0 { + bgra.extend_from_slice(&[0x00, 0x00, 0x00, 0xFF]); + } else { + bgra.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); + } + } + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_8x1_all_unique_colors() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra: Vec = (0..8u8).flat_map(|i| [i * 30, i * 20, i * 10, 0xFF]).collect(); + let wire = enc.encode(&bgra, 8, 1); + let result = dec.decode(&wire, 8, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_100x100_triggers_medium_run_encoding() { + // 10,000 pixels requires factor2 (u16) encoding tier in residual layer + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x42, 0x84, 0xC6, 10_000); + let wire = enc.encode(&bgra, 100, 100); + let result = dec.decode(&wire, 100, 100).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_asymmetric_1x1000() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xAB, 0xCD, 0xEF, 1000); + let wire = enc.encode(&bgra, 1, 1000); + let result = dec.decode(&wire, 1, 1000).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_asymmetric_1000x1() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x11, 0x22, 0x33, 1000); + let wire = enc.encode(&bgra, 1000, 1); + let result = dec.decode(&wire, 1000, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_at_glyph_cache_boundary_1024_pixels() { + // 32x32 = 1024 pixels: maximum size eligible for glyph caching + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x80, 0x80, 0x80, 1024); + let wire = enc.encode(&bgra, 32, 32); + let result = dec.decode(&wire, 32, 32).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_over_glyph_threshold_no_caching() { + // 33x32 = 1056 pixels: too large for glyph caching + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x80, 0x80, 0x80, 1056); + let wire = enc.encode(&bgra, 33, 32); + let result = dec.decode(&wire, 33, 32).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_two_color_stripe() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let mut bgra = Vec::new(); + for _ in 0..50 { + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); + } + for _ in 0..50 { + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); + } + let wire = enc.encode(&bgra, 100, 1); + let result = dec.decode(&wire, 100, 1).unwrap(); + assert_eq!(result, bgra); +} + +// ============================================================================ +// Adversarial Input (no panic, no hang, correct errors) +// ============================================================================ + +#[test] +fn adversarial_residual_max_run_length_completes_quickly() { + // run_length = u32::MAX in a 1x1 surface: must not spin for 4B iterations + let mut dec = ClearCodecDecoder::new(); + let residual = [0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1, 1).unwrap(); + assert_eq!(result.len(), 4); + assert_eq!(&result[..3], &[0xFF, 0x00, 0x00]); // BGR written correctly +} + +#[test] +fn adversarial_residual_zero_run_produces_empty_output() { + let mut dec = ClearCodecDecoder::new(); + let residual = [0xFF, 0x00, 0x00, 0x00]; // run = 0 + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1, 1).unwrap(); + assert_eq!(result, vec![0; 4]); // output stays zeroed +} + +#[test] +fn adversarial_glyph_hit_for_uncached_index() { + let mut dec = ClearCodecDecoder::new(); + let mut data = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x00]; + data.extend_from_slice(&42u16.to_le_bytes()); + assert!(dec.decode(&data, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_hit_without_glyph_index_flag() { + let mut dec = ClearCodecDecoder::new(); + let data = [FLAG_GLYPH_HIT, 0x00]; + assert!(dec.decode(&data, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_index_out_of_spec_range() { + let mut dec = ClearCodecDecoder::new(); + // glyphIndex = 4000 (spec requires 0-3999) + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(4000), &residual); + assert!(dec.decode(&stream, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_index_max_u16() { + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(u16::MAX), &residual); + assert!(dec.decode(&stream, 1, 1).is_err()); +} + +#[test] +fn adversarial_composite_byte_count_overflow() { + let mut data = vec![0x00, 0x00]; // flags, seq + // residualByteCount + bandsByteCount overflows usize + data.extend_from_slice(&0xFFFFFFFFu32.to_le_bytes()); + data.extend_from_slice(&1u32.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +#[test] +fn adversarial_sequence_number_wraps_at_256() { + let mut dec = ClearCodecDecoder::new(); + // Drive sequence through 0..255 and back to 0 (wrapping) + for seq in 0..=255u8 { + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(seq, 0, None, &residual); + dec.decode(&stream, 1, 1).unwrap(); + } + // Wrap back to 0 + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, 0, None, &residual); + dec.decode(&stream, 1, 1).unwrap(); +} + +#[test] +fn adversarial_stream_truncated_to_1_byte() { + let data = [0x00]; + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +#[test] +fn adversarial_stream_empty() { + let data = []; + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +// ============================================================================ +// Cache State Management +// ============================================================================ + +#[test] +fn glyph_cache_store_then_hit() { + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xFF, 0x00, 0x00, 1); + + // Frame 1: store glyph at index 42 + let residual = make_solid_residual(0xFF, 0x00, 0x00, 1); + let stream1 = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(42), &residual); + let p1 = dec.decode(&stream1, 1, 1).unwrap(); + assert_eq!(p1, bgra); + + // Frame 2: glyph hit at index 42 + let mut stream2 = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x01]; + stream2.extend_from_slice(&42u16.to_le_bytes()); + let p2 = dec.decode(&stream2, 1, 1).unwrap(); + assert_eq!(p2, bgra); +} + +#[test] +fn glyph_cache_overwrite_at_same_index() { + let mut dec = ClearCodecDecoder::new(); + + // Store red at index 0 + let red_residual = make_solid_residual(0x00, 0x00, 0xFF, 1); + let stream1 = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(0), &red_residual); + dec.decode(&stream1, 1, 1).unwrap(); + + // Overwrite with blue at index 0 + let blue_residual = make_solid_residual(0xFF, 0x00, 0x00, 1); + let stream2 = make_residual_stream(1, FLAG_GLYPH_INDEX, Some(0), &blue_residual); + dec.decode(&stream2, 1, 1).unwrap(); + + // Hit should return blue + let mut stream3 = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x02]; + stream3.extend_from_slice(&0u16.to_le_bytes()); + let result = dec.decode(&stream3, 1, 1).unwrap(); + assert_eq!(result[0], 0xFF); // blue channel +} + +#[test] +fn cache_reset_does_not_panic() { + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream1 = make_residual_stream(0, 0, None, &residual); + dec.decode(&stream1, 1, 1).unwrap(); + + let stream2 = [FLAG_CACHE_RESET, 0x01]; + let _ = dec.decode(&stream2, 0, 0); +} + +#[test] +fn encoder_glyph_hit_produces_smaller_output() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0xAA, 0xBB, 0xCC, 1); + + let first = enc.encode(&bgra, 1, 1); + let second = enc.encode(&bgra, 1, 1); // should be glyph hit + + assert!(second.len() < first.len(), "glyph hit should be smaller"); +} + +#[test] +fn encoder_glyph_miss_after_content_change() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let red = solid_bgra(0x00, 0x00, 0xFF, 1); + let blue = solid_bgra(0xFF, 0x00, 0x00, 1); + + let first = enc.encode(&red, 1, 1); + let second = enc.encode(&blue, 1, 1); // different content, full encode + + // Verify both are full encodes (not glyph hits) by checking they + // contain a composite header (minimum 14 bytes: flags + seq + 3*u32) + assert!(second.len() >= 14, "changed content should produce a full encode"); + + // Verify they decode to the correct distinct colors + let decoded_red = dec.decode(&first, 1, 1).unwrap(); + let decoded_blue = dec.decode(&second, 1, 1).unwrap(); + assert_eq!(decoded_red, red); + assert_eq!(decoded_blue, blue); +} + +#[test] +fn encoder_sequence_numbers_increment_correctly() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0, 0, 0, 1); + + let e1 = enc.encode(&bgra, 1, 1); + let e2 = enc.encode(&bgra, 1, 1); // glyph hit + let e3 = enc.encode(&solid_bgra(0xFF, 0xFF, 0xFF, 1), 1, 1); // different + + assert_eq!(e1[1], 0); // seq byte at offset 1 + assert_eq!(e2[1], 1); + assert_eq!(e3[1], 2); +} + +#[test] +fn encoder_cache_reset_round_trips() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let reset = enc.encode_cache_reset(); + let _ = dec.decode(&reset, 0, 0); +} + +// ============================================================================ +// Compression Quality +// ============================================================================ + +#[test] +fn solid_color_compresses_below_30_bytes() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0x42, 0x84, 0xC6, 10_000); + let wire = enc.encode(&bgra, 100, 100); + // 10,000 pixels = 40,000 bytes raw. Solid color: header + 1 run segment. + assert!( + wire.len() < 30, + "solid 100x100 should compress to <30 bytes, got {}", + wire.len() + ); +} + +#[test] +fn unique_pixels_do_not_expand_beyond_raw() { + let mut enc = ClearCodecEncoder::new(); + let bgra: Vec = (0..100u8) + .flat_map(|i| [i, i.wrapping_mul(2), i.wrapping_mul(3), 0xFF]) + .collect(); + let wire = enc.encode(&bgra, 100, 1); + // Worst case: each pixel is unique, 1 segment per pixel. + // Should not be larger than raw + header overhead. + assert!(wire.len() < bgra.len() + 50); +} + +// ============================================================================ +// Multi-Frame Session Simulation +// ============================================================================ + +#[test] +fn session_10_frames_mixed_colors() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + let colors: Vec<(u8, u8, u8)> = vec![ + (0, 0, 0), + (0xFF, 0, 0), + (0, 0xFF, 0), + (0, 0, 0xFF), + (0xFF, 0xFF, 0), + (0xFF, 0, 0xFF), + (0, 0xFF, 0xFF), + (0x80, 0x80, 0x80), + (0xFF, 0xFF, 0xFF), + (0, 0, 0), + ]; + + for (b, g, r) in &colors { + let bgra = solid_bgra(*b, *g, *r, 4); + let wire = enc.encode(&bgra, 2, 2); + let result = dec.decode(&wire, 2, 2).unwrap(); + assert_eq!(result, bgra); + } +} + +#[test] +fn session_repeated_frames_hit_glyph_cache() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xDE, 0xAD, 0xBE, 4); + + let wire1 = enc.encode(&bgra, 2, 2); + let len1 = wire1.len(); + dec.decode(&wire1, 2, 2).unwrap(); + + // Subsequent encodes should be glyph hits (smaller) + for _ in 0..5 { + let wire = enc.encode(&bgra, 2, 2); + assert!(wire.len() < len1, "repeated frame should use glyph cache"); + let result = dec.decode(&wire, 2, 2).unwrap(); + assert_eq!(result, bgra); + } +} + +#[test] +fn session_encoder_decoder_stay_synchronized_across_50_frames() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + for i in 0u8..50 { + let bgra = solid_bgra(i, i.wrapping_mul(3), i.wrapping_mul(7), 9); + let wire = enc.encode(&bgra, 3, 3); + let result = dec.decode(&wire, 3, 3).unwrap(); + assert_eq!(result, bgra, "mismatch at frame {i}"); + } +} + +// ============================================================================ +// Bands Layer Compositing (integration through decoder) +// ============================================================================ + +#[test] +fn decode_stream_with_bands_layer_short_vbar_cache_miss() { + // Construct a minimal ClearCodec stream with a bands layer containing + // one band, one column, using a ShortVBarCacheMiss. This exercises the + // full decode_composite -> resolve_vbar -> blit path. + let mut dec = ClearCodecDecoder::new(); + + // Surface: 4 pixels wide, 4 pixels tall + let width: u16 = 4; + let height: u16 = 4; + + // Build bands layer data: one band covering column 1, rows 0-3 + let mut bands_data = Vec::new(); + bands_data.extend_from_slice(&1u16.to_le_bytes()); // x_start = 1 + bands_data.extend_from_slice(&1u16.to_le_bytes()); // x_end = 1 (1 column) + bands_data.extend_from_slice(&0u16.to_le_bytes()); // y_start = 0 + bands_data.extend_from_slice(&3u16.to_le_bytes()); // y_end = 3 (height = 4) + bands_data.extend_from_slice(&[0x00, 0x00, 0x00]); // background BGR = black + + // V-bar: ShortCacheMiss with y_on=1, y_off=3 (2 pixels at rows 1-2) + // bits 13:6 = y_on (1), bits 5:0 = y_off (3) + let vbar_word: u16 = (1 << 6) | 3; + bands_data.extend_from_slice(&vbar_word.to_le_bytes()); + // 2 pixels * 3 bytes = 6 bytes of BGR pixel data (red) + bands_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // row 1: red + bands_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // row 2: red + + // Build the full stream: no residual, bands only, no subcodec + let mut stream = Vec::new(); + stream.push(0x00); // flags + stream.push(0x00); // seq + stream.extend_from_slice(&0u32.to_le_bytes()); // residualByteCount = 0 + let bands_len = u32::try_from(bands_data.len()).unwrap(); + stream.extend_from_slice(&bands_len.to_le_bytes()); // bandsByteCount + stream.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount = 0 + stream.extend_from_slice(&bands_data); + + let pixels = dec.decode(&stream, width, height).unwrap(); + assert_eq!(pixels.len(), usize::from(width) * usize::from(height) * 4); + + // Check column 1, row 1: should be red (from short V-bar pixel data) + let row1_col1 = (usize::from(width) + 1) * 4; + assert_eq!(pixels[row1_col1], 0x00, "blue channel at (1,1)"); + assert_eq!(pixels[row1_col1 + 1], 0x00, "green channel at (1,1)"); + assert_eq!(pixels[row1_col1 + 2], 0xFF, "red channel at (1,1)"); + + // Check column 1, row 0: should be background (black, from band bkg) + let row0_col1 = 4; // row=0, col=1 -> offset 4 + assert_eq!(pixels[row0_col1], 0x00, "blue channel at (1,0)"); + assert_eq!(pixels[row0_col1 + 1], 0x00, "green channel at (1,0)"); + assert_eq!(pixels[row0_col1 + 2], 0x00, "red channel at (1,0)"); + + // Check column 1, row 3: should also be background + let idx3 = (3 * usize::from(width) + 1) * 4; + assert_eq!(pixels[idx3], 0x00, "blue channel at (1,3)"); + assert_eq!(pixels[idx3 + 1], 0x00, "green channel at (1,3)"); + assert_eq!(pixels[idx3 + 2], 0x00, "red channel at (1,3)"); +} + +#[test] +fn adversarial_large_dimensions_rejected() { + // 65535x65535 would allocate ~17GB. The decoder should reject it. + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, 0, None, &residual); + assert!(dec.decode(&stream, u16::MAX, u16::MAX).is_err()); +} + +#[test] +fn large_but_reasonable_dimensions_accepted() { + // 1920x1080 = 2,073,600 pixels should work fine + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0x42, 0x42, 0x42, 1920 * 1080); + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1920, 1080).unwrap(); + assert_eq!(result.len(), 1920 * 1080 * 4); + // Spot-check first pixel + assert_eq!(&result[..4], &[0x42, 0x42, 0x42, 0xFF]); +} diff --git a/crates/ironrdp-testsuite-core/tests/graphics/mod.rs b/crates/ironrdp-testsuite-core/tests/graphics/mod.rs index 50aa61f6e7..9846336c25 100644 --- a/crates/ironrdp-testsuite-core/tests/graphics/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/graphics/mod.rs @@ -1,3 +1,4 @@ +mod clearcodec; mod color_conversion; mod dwt; mod image_processing; From 7dd4db0f6234da95f8d003bea406fbe74e238b03 Mon Sep 17 00:00:00 2001 From: devolutionsbot <31221910+devolutionsbot@users.noreply.github.com> Date: Thu, 28 May 2026 11:49:11 -0400 Subject: [PATCH 261/325] chore(release): prepare for publishing (#1212) --- Cargo.lock | 76 ++++++------- crates/iron-remote-desktop/CHANGELOG.md | 20 +++- crates/iron-remote-desktop/Cargo.toml | 2 +- crates/ironrdp-acceptor/CHANGELOG.md | 21 ++++ crates/ironrdp-acceptor/Cargo.toml | 12 +-- crates/ironrdp-ainput/CHANGELOG.md | 19 +++- crates/ironrdp-ainput/Cargo.toml | 7 +- crates/ironrdp-async/CHANGELOG.md | 8 +- crates/ironrdp-async/Cargo.toml | 8 +- crates/ironrdp-blocking/CHANGELOG.md | 7 ++ crates/ironrdp-blocking/Cargo.toml | 8 +- crates/ironrdp-bulk/CHANGELOG.md | 21 ++++ crates/ironrdp-bulk/Cargo.toml | 2 +- crates/ironrdp-client/Cargo.toml | 8 +- crates/ironrdp-cliprdr-format/CHANGELOG.md | 7 ++ crates/ironrdp-cliprdr-format/Cargo.toml | 4 +- crates/ironrdp-cliprdr-native/CHANGELOG.md | 9 ++ crates/ironrdp-cliprdr-native/Cargo.toml | 6 +- crates/ironrdp-cliprdr/CHANGELOG.md | 94 +++++++++------- crates/ironrdp-cliprdr/Cargo.toml | 8 +- crates/ironrdp-connector/CHANGELOG.md | 90 +++++++++++++++- crates/ironrdp-connector/Cargo.toml | 10 +- crates/ironrdp-core/CHANGELOG.md | 7 +- crates/ironrdp-core/Cargo.toml | 4 +- crates/ironrdp-displaycontrol/CHANGELOG.md | 8 +- crates/ironrdp-displaycontrol/Cargo.toml | 10 +- crates/ironrdp-dvc-com-plugin/CHANGELOG.md | 13 +++ crates/ironrdp-dvc-com-plugin/Cargo.toml | 10 +- crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md | 6 ++ crates/ironrdp-dvc-pipe-proxy/Cargo.toml | 10 +- crates/ironrdp-dvc/CHANGELOG.md | 31 ++++++ crates/ironrdp-dvc/Cargo.toml | 8 +- crates/ironrdp-echo/CHANGELOG.md | 10 +- crates/ironrdp-echo/Cargo.toml | 10 +- crates/ironrdp-egfx/Cargo.toml | 8 +- crates/ironrdp-error/CHANGELOG.md | 25 ++++- crates/ironrdp-error/Cargo.toml | 2 +- crates/ironrdp-futures/CHANGELOG.md | 6 ++ crates/ironrdp-futures/Cargo.toml | 4 +- crates/ironrdp-graphics/CHANGELOG.md | 53 +++++++++ crates/ironrdp-graphics/Cargo.toml | 6 +- crates/ironrdp-input/CHANGELOG.md | 8 +- crates/ironrdp-input/Cargo.toml | 4 +- crates/ironrdp-mstsgu/Cargo.toml | 4 +- crates/ironrdp-pdu/CHANGELOG.md | 78 ++++++++++++++ crates/ironrdp-pdu/Cargo.toml | 6 +- crates/ironrdp-rdpdr-native/CHANGELOG.md | 15 +++ crates/ironrdp-rdpdr-native/Cargo.toml | 10 +- crates/ironrdp-rdpdr/CHANGELOG.md | 31 ++++++ crates/ironrdp-rdpdr/Cargo.toml | 10 +- crates/ironrdp-rdpeusb/Cargo.toml | 4 +- crates/ironrdp-rdpsnd-native/CHANGELOG.md | 13 +++ crates/ironrdp-rdpsnd-native/Cargo.toml | 4 +- crates/ironrdp-rdpsnd/CHANGELOG.md | 28 ++++- crates/ironrdp-rdpsnd/Cargo.toml | 8 +- crates/ironrdp-server/CHANGELOG.md | 118 ++++++++++++++++++++- crates/ironrdp-server/Cargo.toml | 28 ++--- crates/ironrdp-session/CHANGELOG.md | 81 +++++++++++++- crates/ironrdp-session/Cargo.toml | 18 ++-- crates/ironrdp-str/CHANGELOG.md | 13 +++ crates/ironrdp-str/Cargo.toml | 4 +- crates/ironrdp-svc/CHANGELOG.md | 14 ++- crates/ironrdp-svc/Cargo.toml | 6 +- crates/ironrdp-tls/CHANGELOG.md | 6 ++ crates/ironrdp-tls/Cargo.toml | 2 +- crates/ironrdp-tokio/CHANGELOG.md | 8 +- crates/ironrdp-tokio/Cargo.toml | 6 +- crates/ironrdp-viewer/Cargo.toml | 4 +- crates/ironrdp/CHANGELOG.md | 9 +- crates/ironrdp/Cargo.toml | 36 +++---- fuzz/Cargo.lock | 104 +++++++++--------- 71 files changed, 1052 insertions(+), 306 deletions(-) create mode 100644 crates/ironrdp-bulk/CHANGELOG.md create mode 100644 crates/ironrdp-dvc-com-plugin/CHANGELOG.md create mode 100644 crates/ironrdp-str/CHANGELOG.md diff --git a/Cargo.lock b/Cargo.lock index 9794ca196c..7eb95b3b3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,9 +47,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher", "cpubits", @@ -1368,9 +1368,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -2383,7 +2383,7 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iron-remote-desktop" -version = "0.7.0" +version = "0.7.1" dependencies = [ "console_error_panic_hook", "tracing", @@ -2395,7 +2395,7 @@ dependencies = [ [[package]] name = "ironrdp" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "async-trait", @@ -2429,7 +2429,7 @@ dependencies = [ [[package]] name = "ironrdp-acceptor" -version = "0.8.0" +version = "0.9.0" dependencies = [ "ironrdp-async", "ironrdp-connector", @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "ironrdp-ainput" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitflags 2.11.1", "ironrdp-core", @@ -2452,7 +2452,7 @@ dependencies = [ [[package]] name = "ironrdp-async" -version = "0.8.0" +version = "0.9.0" dependencies = [ "bytes", "ironrdp-connector", @@ -2473,7 +2473,7 @@ dependencies = [ [[package]] name = "ironrdp-blocking" -version = "0.8.0" +version = "0.9.0" dependencies = [ "bytes", "ironrdp-connector", @@ -2484,7 +2484,7 @@ dependencies = [ [[package]] name = "ironrdp-bulk" -version = "0.1.0" +version = "0.1.1" dependencies = [ "criterion", ] @@ -2523,7 +2523,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitflags 2.11.1", "ironrdp-core", @@ -2535,7 +2535,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-format" -version = "0.1.4" +version = "0.2.0" dependencies = [ "ironrdp-core", "png", @@ -2543,7 +2543,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-native" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironrdp-cliprdr", "ironrdp-core", @@ -2553,7 +2553,7 @@ dependencies = [ [[package]] name = "ironrdp-connector" -version = "0.8.0" +version = "0.9.0" dependencies = [ "ironrdp-core", "ironrdp-error", @@ -2570,14 +2570,14 @@ dependencies = [ [[package]] name = "ironrdp-core" -version = "0.1.5" +version = "0.2.0" dependencies = [ "ironrdp-error", ] [[package]] name = "ironrdp-displaycontrol" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2588,7 +2588,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2598,7 +2598,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc-com-plugin" -version = "0.1.0" +version = "0.1.1" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2611,7 +2611,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc-pipe-proxy" -version = "0.3.0" +version = "0.4.0" dependencies = [ "async-trait", "ironrdp-core", @@ -2624,7 +2624,7 @@ dependencies = [ [[package]] name = "ironrdp-echo" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2648,11 +2648,11 @@ dependencies = [ [[package]] name = "ironrdp-error" -version = "0.1.3" +version = "0.2.0" [[package]] name = "ironrdp-futures" -version = "0.6.0" +version = "0.7.0" dependencies = [ "futures-util", "ironrdp-async", @@ -2678,7 +2678,7 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.7.0" +version = "0.8.0" dependencies = [ "bit_field", "bitflags 2.11.1", @@ -2696,7 +2696,7 @@ dependencies = [ [[package]] name = "ironrdp-input" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitvec", "ironrdp-pdu", @@ -2725,7 +2725,7 @@ dependencies = [ [[package]] name = "ironrdp-pdu" -version = "0.7.0" +version = "0.8.0" dependencies = [ "arbitrary", "bit_field", @@ -2766,7 +2766,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitflags 2.11.1", "ironrdp-core", @@ -2778,7 +2778,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr-native" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2806,7 +2806,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.7.0" +version = "0.8.0" dependencies = [ "bitflags 2.11.1", "ironrdp-core", @@ -2817,7 +2817,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd-native" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "bytemuck", @@ -2830,7 +2830,7 @@ dependencies = [ [[package]] name = "ironrdp-server" -version = "0.10.0" +version = "0.11.0" dependencies = [ "anyhow", "async-trait", @@ -2862,7 +2862,7 @@ dependencies = [ [[package]] name = "ironrdp-session" -version = "0.8.0" +version = "0.9.0" dependencies = [ "ironrdp-bulk", "ironrdp-connector", @@ -2884,7 +2884,7 @@ version = "0.0.0" [[package]] name = "ironrdp-str" -version = "0.1.0" +version = "0.1.1" dependencies = [ "bytemuck", "ironrdp-core", @@ -2892,7 +2892,7 @@ dependencies = [ [[package]] name = "ironrdp-svc" -version = "0.6.0" +version = "0.7.0" dependencies = [ "bitflags 2.11.1", "ironrdp-core", @@ -2960,7 +2960,7 @@ dependencies = [ [[package]] name = "ironrdp-tls" -version = "0.2.0" +version = "0.2.1" dependencies = [ "tokio", "tokio-native-tls", @@ -2970,7 +2970,7 @@ dependencies = [ [[package]] name = "ironrdp-tokio" -version = "0.8.0" +version = "0.9.0" dependencies = [ "ironrdp-async", "ironrdp-connector", @@ -3361,9 +3361,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" diff --git a/crates/iron-remote-desktop/CHANGELOG.md b/crates/iron-remote-desktop/CHANGELOG.md index 2f076f11bf..10bf54f311 100644 --- a/crates/iron-remote-desktop/CHANGELOG.md +++ b/crates/iron-remote-desktop/CHANGELOG.md @@ -6,6 +6,25 @@ 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). +## [[0.7.1](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.7.0...iron-remote-desktop-v0.7.1)] - 2026-05-27 + +### Features + +- Expose granular RDCleanPath error details ([#1117](https://github.com/Devolutions/IronRDP/issues/1117)) ([2911124e8f](https://github.com/Devolutions/IronRDP/commit/2911124e8fe6160bc8ba03a574b67077e6d2cca9)) + + Add RDCleanPathDetails struct to provide detailed error information for + RDCleanPath errors, including HTTP status codes, WSA error codes, and + TLS alert codes. + + Allows the web client to distinguish between different types of network + errors (say, WSAEACCES/10013) instead of showing a generic RDCleanpath + error message. + +- Implement clipboard file transfer support ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Add end-to-end clipboard file transfer (upload and download) across the + CLIPRDR channel per MS-RDPECLIP. + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.6.0...iron-remote-desktop-v0.7.0)] - 2025-09-29 ### Bug Fixes @@ -36,4 +55,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [**breaking**] Rename extension_call to invoke_extension (#803) ([f68cd06ac3](https://github.com/Devolutions/IronRDP/commit/f68cd06ac3705608e6f2ac6bde684d9ae906ea53)) - diff --git a/crates/iron-remote-desktop/Cargo.toml b/crates/iron-remote-desktop/Cargo.toml index c20514ad46..cbd4ce809f 100644 --- a/crates/iron-remote-desktop/Cargo.toml +++ b/crates/iron-remote-desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iron-remote-desktop" -version = "0.7.0" +version = "0.7.1" readme = "README.md" description = "Helper crate for building WASM modules compatible with iron-remote-desktop WebComponent" edition.workspace = true diff --git a/crates/ironrdp-acceptor/CHANGELOG.md b/crates/ironrdp-acceptor/CHANGELOG.md index 4918e46fb7..95df3b7ff7 100644 --- a/crates/ironrdp-acceptor/CHANGELOG.md +++ b/crates/ironrdp-acceptor/CHANGELOG.md @@ -6,6 +6,27 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.8.0...ironrdp-acceptor-v0.9.0)] - 2026-05-27 + +### Bug Fixes + +- Send RDP_NEG_FAILURE on security protocol mismatch ([#1152](https://github.com/Devolutions/IronRDP/issues/1152)) ([02b9f4efbb](https://github.com/Devolutions/IronRDP/commit/02b9f4efbbe634a50efa0601f30e0a2096a6f78e)) + + When the client and server have no common security protocol, the + acceptor now sends a proper `RDP_NEG_FAILURE` PDU before returning an + error, instead of dropping the TCP connection. + +### Features + +- Expose received client credentials in AcceptorResult ([#1155](https://github.com/Devolutions/IronRDP/issues/1155)) ([eda32d8acf](https://github.com/Devolutions/IronRDP/commit/eda32d8acffbb2e37d13c790105ff022067f5efb)) + +- Skip credential check when server credentials are None ([#1150](https://github.com/Devolutions/IronRDP/issues/1150)) ([84015c9467](https://github.com/Devolutions/IronRDP/commit/84015c946731579dfd7a49294b2e55259e4f8d3f)) + +### Build + +- Upgrade sspi to 0.19, picky to rc.22, fix NTLM fallback ([#1188](https://github.com/Devolutions/IronRDP/issues/1188)) ([c70d38a9f1](https://github.com/Devolutions/IronRDP/commit/c70d38a9f190d6ad6c84bd9027a388b5db3296ba)) + + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.7.0...ironrdp-acceptor-v0.8.0)] - 2025-12-18 ### Bug Fixes diff --git a/crates/ironrdp-acceptor/Cargo.toml b/crates/ironrdp-acceptor/Cargo.toml index 67dd6dc954..68e8e3383e 100644 --- a/crates/ironrdp-acceptor/Cargo.toml +++ b/crates/ironrdp-acceptor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-acceptor" -version = "0.8.0" +version = "0.9.0" readme = "README.md" description = "State machines to drive an RDP connection acceptance sequence" edition.workspace = true @@ -17,11 +17,11 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.8" } # public -ironrdp-async = { path = "../ironrdp-async", version = "0.8" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public +ironrdp-async = { path = "../ironrdp-async", version = "0.9" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-ainput/CHANGELOG.md b/crates/ironrdp-ainput/CHANGELOG.md index 188f0c3ea6..4a6d184ebe 100644 --- a/crates/ironrdp-ainput/CHANGELOG.md +++ b/crates/ironrdp-ainput/CHANGELOG.md @@ -6,6 +6,23 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.5.0...ironrdp-ainput-v0.6.0)] - 2026-05-27 + +### Bug Fixes + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.2.0...ironrdp-ainput-v0.2.1)] - 2025-05-27 ### Build @@ -13,7 +30,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump bitflags from 2.9.0 to 2.9.1 in the patch group across 1 directory (#792) ([87ed315bc2](https://github.com/Devolutions/IronRDP/commit/87ed315bc28fdd2dcfea89b052fa620a7e346e5a)) - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.1.2...ironrdp-ainput-v0.1.3)] - 2025-03-12 ### Build @@ -28,7 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.1.0...ironrdp-ainput-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-ainput/Cargo.toml b/crates/ironrdp-ainput/Cargo.toml index 302cd3806e..70cea3357b 100644 --- a/crates/ironrdp-ainput/Cargo.toml +++ b/crates/ironrdp-ainput/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-ainput" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "AInput dynamic channel implementation" edition.workspace = true @@ -17,12 +17,11 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public bitflags = "2.11" num-derive.workspace = true # TODO: remove num-traits.workspace = true # TODO: remove [lints] workspace = true - diff --git a/crates/ironrdp-async/CHANGELOG.md b/crates/ironrdp-async/CHANGELOG.md index d98372c7f5..8bad242557 100644 --- a/crates/ironrdp-async/CHANGELOG.md +++ b/crates/ironrdp-async/CHANGELOG.md @@ -6,6 +6,13 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.8.0...ironrdp-async-v0.9.0)] - 2026-05-27 + +### Bug Fixes + +- [**breaking**] Make Framed::read_exact crate-private ([#1247](https://github.com/Devolutions/IronRDP/issues/1247)) ([d02d24aad4](https://github.com/Devolutions/IronRDP/commit/d02d24aad44039c0425a022f1bd9677800706cea)) + + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.7.0...ironrdp-async-v0.8.0)] - 2025-12-18 ### Bug Fixes @@ -40,7 +47,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.2.0...ironrdp-async-v0.2.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-async/Cargo.toml b/crates/ironrdp-async/Cargo.toml index 2d1ef46052..777444e28c 100644 --- a/crates/ironrdp-async/Cargo.toml +++ b/crates/ironrdp-async/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-async" -version = "0.8.0" +version = "0.9.0" readme = "README.md" description = "Provides `Future`s wrapping the IronRDP state machines conveniently" edition.workspace = true @@ -17,9 +17,9 @@ doctest = false test = false [dependencies] -ironrdp-connector = { path = "../ironrdp-connector", version = "0.8" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } bytes = "1" # public diff --git a/crates/ironrdp-blocking/CHANGELOG.md b/crates/ironrdp-blocking/CHANGELOG.md index 02048123a9..27d49e34b2 100644 --- a/crates/ironrdp-blocking/CHANGELOG.md +++ b/crates/ironrdp-blocking/CHANGELOG.md @@ -6,6 +6,13 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.8.0...ironrdp-blocking-v0.9.0)] - 2026-05-27 + +### Bug Fixes + +- [**breaking**] Make Framed::read_exact crate-private ([#1247](https://github.com/Devolutions/IronRDP/issues/1247)) ([d02d24aad4](https://github.com/Devolutions/IronRDP/commit/d02d24aad44039c0425a022f1bd9677800706cea)) + + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.7.0...ironrdp-blocking-v0.8.0)] - 2025-12-18 ### Bug Fixes diff --git a/crates/ironrdp-blocking/Cargo.toml b/crates/ironrdp-blocking/Cargo.toml index fb8fe2ac1e..28791a81a8 100644 --- a/crates/ironrdp-blocking/Cargo.toml +++ b/crates/ironrdp-blocking/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-blocking" -version = "0.8.0" +version = "0.9.0" readme = "README.md" description = "Blocking I/O abstraction wrapping the IronRDP state machines conveniently" edition.workspace = true @@ -17,9 +17,9 @@ doctest = false test = false [dependencies] -ironrdp-connector = { path = "../ironrdp-connector", version = "0.8" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } bytes = "1" # public diff --git a/crates/ironrdp-bulk/CHANGELOG.md b/crates/ironrdp-bulk/CHANGELOG.md new file mode 100644 index 0000000000..cfbe2a4854 --- /dev/null +++ b/crates/ironrdp-bulk/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-bulk-v0.1.0...ironrdp-bulk-v0.1.1)] - 2026-05-27 + +### Bug Fixes + +- Gate alloc-dependent modules behind the alloc feature ([#1279](https://github.com/Devolutions/IronRDP/issues/1279)) ([18a430a51a](https://github.com/Devolutions/IronRDP/commit/18a430a51aca07aa45db4642df5c932ef65d2016)) + +- Off-by-one in forward match loop causes panic during compression ([#1293](https://github.com/Devolutions/IronRDP/issues/1293)) ([0dd7c94ba2](https://github.com/Devolutions/IronRDP/commit/0dd7c94ba22e9bd11b4ea36fd03af3bfcccecab8)) + +### Build + +- Bump criterion from 0.5.1 to 0.8.1 ([#1184](https://github.com/Devolutions/IronRDP/issues/1184)) ([d92dd382b3](https://github.com/Devolutions/IronRDP/commit/d92dd382b3fbaa163f355f6489db45ca8a3e7498)) + + diff --git a/crates/ironrdp-bulk/Cargo.toml b/crates/ironrdp-bulk/Cargo.toml index 95d2abed9f..6cc8115eba 100644 --- a/crates/ironrdp-bulk/Cargo.toml +++ b/crates/ironrdp-bulk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-bulk" -version = "0.1.0" +version = "0.1.1" description = "Bulk compression algorithms (MPPC, XCRUSH, NCRUSH) for IronRDP" edition.workspace = true rust-version = "1.89" diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 4a393f5779..14f25786f2 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -27,7 +27,7 @@ qoiz = ["ironrdp/qoiz"] [dependencies] # Protocols -ironrdp = { path = "../ironrdp", version = "0.14", features = [ +ironrdp = { path = "../ironrdp", version = "0.15", features = [ "session", "input", "graphics", @@ -40,11 +40,11 @@ ironrdp = { path = "../ironrdp", version = "0.14", features = [ "connector", "echo", ] } -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } -ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.5" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } +ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.6" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } -ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.8", features = ["reqwest"] } +ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.9", features = ["reqwest"] } ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" diff --git a/crates/ironrdp-cliprdr-format/CHANGELOG.md b/crates/ironrdp-cliprdr-format/CHANGELOG.md index 717d9c1c89..8af3400cef 100644 --- a/crates/ironrdp-cliprdr-format/CHANGELOG.md +++ b/crates/ironrdp-cliprdr-format/CHANGELOG.md @@ -6,6 +6,13 @@ 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). +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.4...ironrdp-cliprdr-format-v0.2.0)] - 2026-05-27 + +### Build + +- Update `ironrdp-core` public dependency to 0.2 ([#965](https://github.com/Devolutions/IronRDP/issues/965)) ([630525deae](https://github.com/Devolutions/IronRDP/commit/630525deae92f39bfed53248ab0fec0e71249322)) + + ## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.3...ironrdp-cliprdr-format-v0.1.4)] - 2025-09-04 ### Build diff --git a/crates/ironrdp-cliprdr-format/Cargo.toml b/crates/ironrdp-cliprdr-format/Cargo.toml index aff6260bf5..c9de09d872 100644 --- a/crates/ironrdp-cliprdr-format/Cargo.toml +++ b/crates/ironrdp-cliprdr-format/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-cliprdr-format" -version = "0.1.4" +version = "0.2.0" readme = "README.md" description = "CLIPRDR format conversion library" edition.workspace = true @@ -17,7 +17,7 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["std"] } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["std"] } # public png = "0.18" [lints] diff --git a/crates/ironrdp-cliprdr-native/CHANGELOG.md b/crates/ironrdp-cliprdr-native/CHANGELOG.md index 20bbaa966c..b6b4ea00a6 100644 --- a/crates/ironrdp-cliprdr-native/CHANGELOG.md +++ b/crates/ironrdp-cliprdr-native/CHANGELOG.md @@ -6,6 +6,15 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.5.0...ironrdp-cliprdr-native-v0.6.0)] - 2026-05-27 + +### Features + +- Implement clipboard file transfer support ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Add end-to-end clipboard file transfer (upload and download) across the + CLIPRDR channel per MS-RDPECLIP. + ## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.4.0...ironrdp-cliprdr-native-v0.5.0)] - 2025-12-18 ### Bug Fixes diff --git a/crates/ironrdp-cliprdr-native/Cargo.toml b/crates/ironrdp-cliprdr-native/Cargo.toml index c98682d660..c95286351b 100644 --- a/crates/ironrdp-cliprdr-native/Cargo.toml +++ b/crates/ironrdp-cliprdr-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-cliprdr-native" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "Native CLIPRDR static channel backend implementations for IronRDP" edition.workspace = true @@ -17,8 +17,8 @@ doctest = false test = false [dependencies] -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.5" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } tracing = { version = "0.1", features = ["log"] } [target.'cfg(windows)'.dependencies] diff --git a/crates/ironrdp-cliprdr/CHANGELOG.md b/crates/ironrdp-cliprdr/CHANGELOG.md index 5479fc0888..d95900dba7 100644 --- a/crates/ironrdp-cliprdr/CHANGELOG.md +++ b/crates/ironrdp-cliprdr/CHANGELOG.md @@ -6,59 +6,76 @@ 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] +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.5.0...ironrdp-cliprdr-v0.6.0)] - 2026-05-27 ### Features -- [**breaking**] Add clipboard file transfer support per MS-RDPECLIP +- [**breaking**] Implement clipboard file transfer support ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) - Implements end-to-end clipboard file transfer (upload and download) across the - CLIPRDR channel. Key changes: + Add end-to-end clipboard file transfer (upload and download) across the + CLIPRDR channel per MS-RDPECLIP. 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. - - 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. + New `CliprdrBackend` methods (with default implementations): + `on_remote_file_list()`, `on_file_contents_request()`, + `on_outgoing_locks_cleared()`, `on_outgoing_locks_expired()`, and + `now_ms()` / `elapsed_ms()` for timeout tracking. New `drive_timeouts()` + method for callers to invoke periodically. Comprehensive path + sanitization to protect against path traversal attacks. -- [**breaking**] Remove `ClipboardMessage::SendLockClipboard` and `SendUnlockClipboard` variants + Breaking changes folded in: removed `ClipboardMessage::SendLockClipboard` + and `SendUnlockClipboard` variants (lock/unlock is now managed + internally); renamed `FileContentsFlags::DATA` to `RANGE` (matches + MS-RDPECLIP 2.2.5.3 terminology); changed `FileContentsRequest::index` + from `u32` to `i32` (per spec); made `FileDescriptor` `#[non_exhaustive]` + and added `relative_path: Option` field (use the builder pattern + instead of struct literals). - 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. +- Add clipboard data locking methods ([#1064](https://github.com/Devolutions/IronRDP/issues/1064)) ([58c3df84bb](https://github.com/Devolutions/IronRDP/commit/58c3df84bb9cafc8669315834cead35a71483c34)) -- [**breaking**] Rename `FileContentsFlags::DATA` to `FileContentsFlags::RANGE` + Per MS-RDPECLIP sections 2.2.4.6 and 2.2.4.7, the local + clipboard owner can lock shared clipboard data before requesting file + contents, ensuring data stability during multi-request transfers. - 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. +- Add request_file_contents method ([#1065](https://github.com/Devolutions/IronRDP/issues/1065)) ([c30fc35a28](https://github.com/Devolutions/IronRDP/commit/c30fc35a28d6218603c1662e98e8b3053bea3aa5)) -- [**breaking**] Change `FileContentsRequest::index` type from `u32` to `i32` + Per MS-RDPECLIP section 2.2.5.3, this adds support + for sending File Contents Request PDUs to retrieve remote file data + during paste operations. - 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. +- Add SendFileContentsResponse message variant ([#1066](https://github.com/Devolutions/IronRDP/issues/1066)) ([25f81337aa](https://github.com/Devolutions/IronRDP/commit/25f81337aa494af9a21f55f12ec27fd946465cbe)) -- [**breaking**] Make `FileDescriptor` `#[non_exhaustive]` and add `relative_path` field + Adds `SendFileContentsResponse` to `ClipboardMessage`, allowing + clipboard backends to signal when file data is ready to be sent via + `submit_file_contents()`. - The `FileDescriptor` struct is now marked `#[non_exhaustive]` to allow future - field additions without breaking changes. A new `relative_path: Option` - field has been added to support directory structure in file transfers. +- Always set FD_PROGRESSUI in FileDescriptor::encode ([#1299](https://github.com/Devolutions/IronRDP/issues/1299)) ([7e0bfd3c55](https://github.com/Devolutions/IronRDP/commit/7e0bfd3c550135a3c9c85cb66a478ce41c8641d9)) - **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), ... }; +- Advertise Preferred DropEffect alongside FileGroupDescriptorW ([#1301](https://github.com/Devolutions/IronRDP/issues/1301)) ([5375bbb9dd](https://github.com/Devolutions/IronRDP/commit/5375bbb9ddb8b853973d050fa2efd0ed217ac17b)) + + `initiate_file_copy` now advertises **both** `FileGroupDescriptorW` and + `Preferred DropEffect` (`CFSTR_PREFERREDDROPEFFECT`) in the FormatList, + and `handle_format_data_request` short-circuits a request for the latter + with `DROPEFFECT_COPY` (0x00000001 LE). + +- [**breaking**] Add CliprdrBackend::on_format_list_response(ok) hook ([#1300](https://github.com/Devolutions/IronRDP/issues/1300)) ([a4bc475360](https://github.com/Devolutions/IronRDP/commit/a4bc4753607d87ef0989d9df16a31cd22e7c7fde)) + +### Bug Fixes + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) - // 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 @@ -120,7 +137,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.1.0...ironrdp-cliprdr-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-cliprdr/Cargo.toml b/crates/ironrdp-cliprdr/Cargo.toml index 0df9d73638..3fe8d0ab02 100644 --- a/crates/ironrdp-cliprdr/Cargo.toml +++ b/crates/ironrdp-cliprdr/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-cliprdr" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "CLIPRDR static channel for clipboard implemented as described in MS-RDPECLIP" edition.workspace = true @@ -22,9 +22,9 @@ test = false __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 +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public tracing = { version = "0.1", features = ["log"] } bitflags = "2.11" visibility = { version = "0.1", optional = true } diff --git a/crates/ironrdp-connector/CHANGELOG.md b/crates/ironrdp-connector/CHANGELOG.md index 888acabe73..1c818e4503 100644 --- a/crates/ironrdp-connector/CHANGELOG.md +++ b/crates/ironrdp-connector/CHANGELOG.md @@ -6,6 +6,92 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.8.0...ironrdp-connector-v0.9.0)] - 2026-05-27 + +### Features + +- Add alternate_shell and work_dir configuration support ([#1095](https://github.com/Devolutions/IronRDP/issues/1095)) ([a33d27fe67](https://github.com/Devolutions/IronRDP/commit/a33d27fe6771a5a155161ef40a04de88803dd84c)) + + Add support for configuring `alternate_shell` and `work_dir` fields in + ClientInfoPdu, which are used by: + - CyberArk PSM (Privileged Session Manager) for session tokens + - Remote application scenarios (RemoteApp) + - Custom shell configurations + +- Dispatch multitransport PDUs on IO channel ([#1096](https://github.com/Devolutions/IronRDP/issues/1096)) ([7853e3cc6f](https://github.com/Devolutions/IronRDP/commit/7853e3cc6f26acaf3da000c6177ca3cef6ef85fd)) + + `decode_io_channel()` assumes all IO channel PDUs begin with + a `ShareControlHeader`. Multitransport Request PDUs use a + `BasicSecurityHeader` with `SEC_TRANSPORT_REQ` instead ([MS-RDPBCGR] + 2.2.15.1). + + This adds a peek-based dispatch: check the first `u16` + for`TRANSPORT_REQ`, decode as `MultitransportRequestPdu` if set, + otherwise fall through to the existing `decode_share_control()` path + unchanged. + + The new variant is propagated through `ProcessorOutput` and + 'ActiveStageOutput` so applications can handle multitransport requests. + Client and web consumers log the request (no UDP transport yet). + +- Add bulk compression and wire negotiation ([ebf5da5f33](https://github.com/Devolutions/IronRDP/commit/ebf5da5f3380a3355f6c95814d669f8190425ded)) + + Add support for bulk compression negotiation and payload decoding, + including connector plumbing, CLI configuration flags, and integration + updates across tests/examples/FFI/web. + +- Advertise multitransport channel in GCC blocks ([#1092](https://github.com/Devolutions/IronRDP/issues/1092)) ([4f5fdd3628](https://github.com/Devolutions/IronRDP/commit/4f5fdd3628f4d0d2c2a4116e4e45269d802740f1)) + + Add multitransport_flags config option to populate the + MultiTransportChannelData GCC block during connection negotiation. + When None (the default), behavior is unchanged. + +### Bug Fixes + +- Propagate negotiated share_id to all outgoing ShareDataPdu ([#1147](https://github.com/Devolutions/IronRDP/issues/1147)) ([2b24e9664d](https://github.com/Devolutions/IronRDP/commit/2b24e9664dd05620ff63a24d092377477fdde863)) + +- Advertise all colour depths per FreeRDP pattern ([#1231](https://github.com/Devolutions/IronRDP/issues/1231)) ([2fa7c648cb](https://github.com/Devolutions/IronRDP/commit/2fa7c648cb4a2fc9c75d967ac878f817900dc1b8)) + + Replace the per-depth supportedColorDepths bitmask with an unconditional + BPP32 | BPP24 | BPP16 | BPP15, following FreeRDP's approach of treating + the field as a capability set rather than a preferred-depth indicator + (libfreerdp/core/settings.c). + + The preferred depth is expressed via the two dedicated fields: + - highColorDepth: now derived from the configured depth (15 → + Rgb555Bpp16 / 0x0F, 16 → Rgb565Bpp16 / 0x10, else Bpp24 / 0x18), + matching FreeRDP's ColorDepthToHighColor() + - WANT_32_BPP_SESSION earlyCapabilityFlag: unchanged, set only for 32bpp + + Previously, a client configured for 24bpp advertised BPP24 only. Modern + Windows hosts (Server 2012+) dropped 24bpp RDP support and reset the + connection instead of negotiating down, leaving no usable depth. With + all four bits always advertised the server can freely negotiate to the + highest depth it supports. + +- Surface actual PDU type when an unexpected Share Control PDU arrives ([#1236](https://github.com/Devolutions/IronRDP/issues/1236)) ([78effb3f91](https://github.com/Devolutions/IronRDP/commit/78effb3f9144a482395be738b2c9fd4d909b7b89)) + +- Handle ServerDeactivateAll during CapabilitiesExchange ([#1254](https://github.com/Devolutions/IronRDP/issues/1254)) ([9cb5439b4a](https://github.com/Devolutions/IronRDP/commit/9cb5439b4a78c4a7facc854464894c7893f6a926)) + + Some RDP servers (notably GNOME Remote Desktop / grd) send a + ServerDeactivateAll PDU before ServerDemandActive during the initial + Capabilities Exchange phase. This is valid per MS-RDPBCGR §1.3.1.3 + (Deactivation-Reactivation Sequence). + + Previously this caused a hard error: + "unexpected Share Control Pdu (expected ServerDemandActive)" + + Now the connector skips the DeactivateAll and waits for the next PDU. + +### Performance + +- Reduce connection latency when Kerberos is disabled ([#1107](https://github.com/Devolutions/IronRDP/issues/1107)) ([b1b0289e00](https://github.com/Devolutions/IronRDP/commit/b1b0289e0067228dbc973d3edb0e27136f7ca52a)) + +### Build + +- Upgrade to sspi 0.21 and picky rc.23 ([#1296](https://github.com/Devolutions/IronRDP/issues/1296)) ([d5b3fa7db8](https://github.com/Devolutions/IronRDP/commit/d5b3fa7db8a4ce74ac9a9aaff3064faf6cb6c920)) + + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.7.1...ironrdp-connector-v0.8.0)] - 2025-12-18 ### Build @@ -83,7 +169,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [**breaking**] Add supported codecs in BitmapConfig ([f03ee393a3](https://github.com/Devolutions/IronRDP/commit/f03ee393a36906114b5bcba0e88ebc6869a99785)) - ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.3.2...ironrdp-connector-v0.4.0)] - 2025-03-12 ### Build @@ -98,7 +183,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update dependencies - ## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.3.0...ironrdp-connector-v0.3.1)] - 2025-01-30 ### Bug Fixes @@ -106,7 +190,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Decrease log verbosity for license exchange ([#655](https://github.com/Devolutions/IronRDP/issues/655)) ([c8597733fe](https://github.com/Devolutions/IronRDP/commit/c8597733fe9998318764064c3682506bf82026d2)) - ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.2.2...ironrdp-connector-v0.3.0)] - 2025-01-28 ### Features @@ -127,7 +210,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump picky from 7.0.0-rc.11 to 7.0.0-rc.12 ([#639](https://github.com/Devolutions/IronRDP/issues/639)) ([a16a131e43](https://github.com/Devolutions/IronRDP/commit/a16a131e4301e0dfafe8f3b73e1a75a3a06cfdc7)) - ## [[0.2.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.2.1...ironrdp-connector-v0.2.2)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-connector/Cargo.toml b/crates/ironrdp-connector/Cargo.toml index 1cea6f5b31..82776e9c56 100644 --- a/crates/ironrdp-connector/Cargo.toml +++ b/crates/ironrdp-connector/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-connector" -version = "0.8.0" +version = "0.9.0" readme = "README.md" description = "State machines to drive an RDP connection sequence" edition.workspace = true @@ -22,10 +22,10 @@ qoi = ["ironrdp-pdu/qoi"] qoiz = ["ironrdp-pdu/qoiz"] [dependencies] -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["std"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["std"] } # public sspi = { version = "0.21", features = ["scard"] } url = "2.5" # public rand = { version = "0.9", features = ["std"] } # TODO: dependency injection? diff --git a/crates/ironrdp-core/CHANGELOG.md b/crates/ironrdp-core/CHANGELOG.md index 96f7bce765..b1b9c17f2d 100644 --- a/crates/ironrdp-core/CHANGELOG.md +++ b/crates/ironrdp-core/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.1.5...ironrdp-core-v0.2.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-error` public dependency to 0.2 + ## [[0.1.5](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.1.4...ironrdp-core-v0.1.5)] - 2025-05-28 ### Features @@ -25,7 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.1.1...ironrdp-core-v0.1.2)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-core/Cargo.toml b/crates/ironrdp-core/Cargo.toml index 0454cb0f75..1ab410a175 100644 --- a/crates/ironrdp-core/Cargo.toml +++ b/crates/ironrdp-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-core" -version = "0.1.5" +version = "0.2.0" readme = "README.md" description = "IronRDP common traits and types" edition.workspace = true @@ -22,4 +22,4 @@ std = ["alloc", "ironrdp-error/std"] alloc = ["ironrdp-error/alloc"] [dependencies] -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public diff --git a/crates/ironrdp-displaycontrol/CHANGELOG.md b/crates/ironrdp-displaycontrol/CHANGELOG.md index 9801a9660a..a501cef804 100644 --- a/crates/ironrdp-displaycontrol/CHANGELOG.md +++ b/crates/ironrdp-displaycontrol/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.5.0...ironrdp-displaycontrol-v0.6.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-core`, `ironrdp-dvc`, `ironrdp-pdu`, and `ironrdp-svc` public dependencies + ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.1.3...ironrdp-displaycontrol-v0.2.0)] - 2025-03-12 ### Build @@ -13,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.1.2...ironrdp-displaycontrol-v0.1.3)] - 2025-03-12 ### Build @@ -28,7 +33,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.1.0...ironrdp-displaycontrol-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-displaycontrol/Cargo.toml b/crates/ironrdp-displaycontrol/Cargo.toml index c5eb081da5..bd4a000c0c 100644 --- a/crates/ironrdp-displaycontrol/Cargo.toml +++ b/crates/ironrdp-displaycontrol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-displaycontrol" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "Display control dynamic channel extension implementation" edition.workspace = true @@ -17,10 +17,10 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-dvc-com-plugin/CHANGELOG.md b/crates/ironrdp-dvc-com-plugin/CHANGELOG.md new file mode 100644 index 0000000000..5068e7226b --- /dev/null +++ b/crates/ironrdp-dvc-com-plugin/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-com-plugin-v0.1.0...ironrdp-dvc-com-plugin-v0.1.1)] - 2026-05-27 + +### Build + +- Update dependencies. diff --git a/crates/ironrdp-dvc-com-plugin/Cargo.toml b/crates/ironrdp-dvc-com-plugin/Cargo.toml index 9928af0285..103a130fb6 100644 --- a/crates/ironrdp-dvc-com-plugin/Cargo.toml +++ b/crates/ironrdp-dvc-com-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc-com-plugin" -version = "0.1.0" +version = "0.1.1" readme = "README.md" description = "DVC COM client plugin loader for IronRDP (Windows)" edition.workspace = true @@ -19,10 +19,10 @@ test = false [dependencies] [target.'cfg(windows)'.dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5" } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } tracing = { version = "0.1", features = ["log"] } windows = { version = "0.62", features = [ "Win32_Foundation", diff --git a/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md index bd2eda4093..05a998fde2 100644 --- a/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md +++ b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.3.0...ironrdp-dvc-pipe-proxy-v0.4.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-pdu` and `ironrdp-svc` public dependencies + ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.2.0...ironrdp-dvc-pipe-proxy-v0.2.1)] - 2025-09-24 ### Bug Fixes diff --git a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml index 62b5ef989d..0d6aefe0dc 100644 --- a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml +++ b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc-pipe-proxy" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "DVC named pipe proxy for IronRDP" edition.workspace = true @@ -17,10 +17,10 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public (PduResult type) -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5" } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public (SvcMessage type) +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public (PduResult type) +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public (SvcMessage type) tracing = { version = "0.1", features = ["log"] } tokio = { version = "1", features = ["net", "rt", "sync", "macros", "io-util", "fs"]} diff --git a/crates/ironrdp-dvc/CHANGELOG.md b/crates/ironrdp-dvc/CHANGELOG.md index 6eb3c6a594..1a8ca413fd 100644 --- a/crates/ironrdp-dvc/CHANGELOG.md +++ b/crates/ironrdp-dvc/CHANGELOG.md @@ -6,6 +6,37 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.5.0...ironrdp-dvc-v0.6.0)] - 2026-05-27 + +### Features + +- Implement ECHO virtual channel ([#1109](https://github.com/Devolutions/IronRDP/issues/1109)) ([6f6496ad29](https://github.com/Devolutions/IronRDP/commit/6f6496ad29395099563d50417d6dfff623914ee6)) + +- Add DvcChannelListener for multi-instance DVC support ([#1142](https://github.com/Devolutions/IronRDP/issues/1142)) ([28e8628f0e](https://github.com/Devolutions/IronRDP/commit/28e8628f0e3cea9f7723a73abf5fd7ed2da968f0)) + +- Close channel API for server and client ([#1302](https://github.com/Devolutions/IronRDP/issues/1302)) ([196d18dfaa](https://github.com/Devolutions/IronRDP/commit/196d18dfaa7ec899946bb90f4dcb8bad31872f48)) + +### Bug Fixes + +- Negotiate DVC version from server capabilities ([d094cbeb75](https://github.com/Devolutions/IronRDP/commit/d094cbeb7501c83fc6ad5401ba69d22f79d6657c)) + + The client was hardcoded to respond with CapsVersion::V1 regardless + of what the server requested. Servers that require V2 or V3 (such + as XRDP) would reject the channel with "Dynamic Virtual Channel + version 1 is not supported." + + Echo the server's requested version in the capabilities response + instead. This correctly handles V1, V2, and V3 depending on what + the server advertises. When a Create arrives before Capabilities + (fallback path), default to V2 as the most broadly compatible + version. + + Also bump the server-side capabilities request from V1 to V2 to + advertise priority charge support. + + Add CapabilitiesRequestPdu::version() accessor to expose the + server's requested version from the parsed PDU. + ## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.4.0...ironrdp-dvc-v0.4.1)] - 2025-09-04 ### Features diff --git a/crates/ironrdp-dvc/Cargo.toml b/crates/ironrdp-dvc/Cargo.toml index 1dd8f51640..1f7b381e8f 100644 --- a/crates/ironrdp-dvc/Cargo.toml +++ b/crates/ironrdp-dvc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "DRDYNVC static channel implementation and traits to implement dynamic virtual channels" edition.workspace = true @@ -21,9 +21,9 @@ default = [] std = [] [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["alloc"] } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-echo/CHANGELOG.md b/crates/ironrdp-echo/CHANGELOG.md index 0e07e46488..7ab4e64d46 100644 --- a/crates/ironrdp-echo/CHANGELOG.md +++ b/crates/ironrdp-echo/CHANGELOG.md @@ -3,4 +3,12 @@ All notable changes to this project will be documented in this file. 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). \ No newline at end of file +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-echo-v0.1.0...ironrdp-echo-v0.2.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-core`, `ironrdp-dvc`, and `ironrdp-pdu` public dependencies + diff --git a/crates/ironrdp-echo/Cargo.toml b/crates/ironrdp-echo/Cargo.toml index 2e61f4effb..6d2a3a610d 100644 --- a/crates/ironrdp-echo/Cargo.toml +++ b/crates/ironrdp-echo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-echo" -version = "0.1.0" +version = "0.2.0" readme = "README.md" description = "Virtual channel echo extension implementation" edition.workspace = true @@ -17,10 +17,10 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } [lints] -workspace = true \ No newline at end of file +workspace = true diff --git a/crates/ironrdp-egfx/Cargo.toml b/crates/ironrdp-egfx/Cargo.toml index a7e8605763..4d16f10f00 100644 --- a/crates/ironrdp-egfx/Cargo.toml +++ b/crates/ironrdp-egfx/Cargo.toml @@ -19,10 +19,10 @@ doctest = false [dependencies] bit_field = "0.10" bitflags = "2.11" -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.7" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public openh264 = { version = "0.9", optional = true, default-features = false } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-error/CHANGELOG.md b/crates/ironrdp-error/CHANGELOG.md index 87e73d30f5..4614e94933 100644 --- a/crates/ironrdp-error/CHANGELOG.md +++ b/crates/ironrdp-error/CHANGELOG.md @@ -6,6 +6,30 @@ 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). +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-error-v0.1.3...ironrdp-error-v0.2.0)] - 2026-05-27 + +### Features + +- Capture core::panic::Location automatically in Error ([#1262](https://github.com/Devolutions/IronRDP/issues/1262)) ([2e2b5edfd7](https://github.com/Devolutions/IronRDP/commit/2e2b5edfd750df35bd8c8dba777ddd45c1a5bc7a)) + + Capture caller location with `#[track_caller]` + `core::panic::Location::caller()` + and include it in `Display` output while keeping `Debug` stable for snapshots. + +- Add bail! and ensure! macros ([#1263](https://github.com/Devolutions/IronRDP/issues/1263)) ([68b86f2b06](https://github.com/Devolutions/IronRDP/commit/68b86f2b06ba9b09a2f9e007dd5f1783b6979cca)) + +### Bug Fixes + +- [**breaking**] Make fields of Error private ([#1074](https://github.com/Devolutions/IronRDP/issues/1074)) ([e51ed236ce](https://github.com/Devolutions/IronRDP/commit/e51ed236ce5d55dc1a4bc5f5809fd106bdd2e834)) + +- Box diagnostic metadata to shrink Error size ([#1269](https://github.com/Devolutions/IronRDP/issues/1269)) ([2e2699d2dc](https://github.com/Devolutions/IronRDP/commit/2e2699d2dc6644d5bbc87f41a987a2db90d281a8)) + + Move context, location, and source into a heap-allocated `ErrorMeta` so + `Error` keeps only kind on the stack, reducing large downstream + error sizes. Since error construction is already `#[cold]`, one `Box` + allocation per error is acceptable. + +- [**breaking**] Remove Error::into_other_kind ([#1278](https://github.com/Devolutions/IronRDP/issues/1278)) ([ac7ad50a50](https://github.com/Devolutions/IronRDP/commit/ac7ad50a501935fdf2ce0e12b6dd737dcb9aa9c9)) + ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-error-v0.1.1...ironrdp-error-v0.1.2)] - 2025-01-28 ### Documentation @@ -13,7 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-error-v0.1.0...ironrdp-error-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-error/Cargo.toml b/crates/ironrdp-error/Cargo.toml index 897f945494..b8b56cbfc2 100644 --- a/crates/ironrdp-error/Cargo.toml +++ b/crates/ironrdp-error/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-error" -version = "0.1.3" +version = "0.2.0" readme = "README.md" description = "IronPDU generic error definition" edition.workspace = true diff --git a/crates/ironrdp-futures/CHANGELOG.md b/crates/ironrdp-futures/CHANGELOG.md index 3a35de720b..6be657678f 100644 --- a/crates/ironrdp-futures/CHANGELOG.md +++ b/crates/ironrdp-futures/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.6.0...ironrdp-futures-v0.7.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-async` public dependency to 0.9 + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.5.0...ironrdp-futures-v0.6.0)] - 2025-12-18 diff --git a/crates/ironrdp-futures/Cargo.toml b/crates/ironrdp-futures/Cargo.toml index 783545d654..3c192b0120 100644 --- a/crates/ironrdp-futures/Cargo.toml +++ b/crates/ironrdp-futures/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-futures" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "`Framed*` traits implementation above futures’s traits" edition.workspace = true @@ -18,7 +18,7 @@ test = false [dependencies] futures-util = { version = "0.3", features = ["io"] } # public -ironrdp-async = { path = "../ironrdp-async", version = "0.8" } # public +ironrdp-async = { path = "../ironrdp-async", version = "0.9" } # public [lints] workspace = true diff --git a/crates/ironrdp-graphics/CHANGELOG.md b/crates/ironrdp-graphics/CHANGELOG.md index 1a7bfd3c09..540a43a67c 100644 --- a/crates/ironrdp-graphics/CHANGELOG.md +++ b/crates/ironrdp-graphics/CHANGELOG.md @@ -6,6 +6,59 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.7.0...ironrdp-graphics-v0.8.0)] - 2026-05-27 + +### Features + +- Add segment wrapping utilities ([#1076](https://github.com/Devolutions/IronRDP/issues/1076)) ([5fa4964807](https://github.com/Devolutions/IronRDP/commit/5fa4964807fa15bbf1a5e3c23b365344758961aa)) + + Adds ZGFX segment wrapping utilities for encoding data in RDP8 format. + +- Add LZ77 compression support ([#1097](https://github.com/Devolutions/IronRDP/issues/1097)) ([48715483a3](https://github.com/Devolutions/IronRDP/commit/48715483a36c824af034a51f4db0580c34825d63)) + + Adds ZGFX (RDP8) LZ77 compression to complement the existing + decompressor, plus a high-level API for EGFX PDU preparation with + auto/always/never mode selection. + + The compressor uses a hash table mapping 3-byte prefixes to history + positions for O(1) match candidate lookup against the 2.5 MB sliding + window. + +- Complete pixel format support for bitmap updates ([#1134](https://github.com/Devolutions/IronRDP/issues/1134)) ([a6b41093ce](https://github.com/Devolutions/IronRDP/commit/a6b41093ce4ece081d2538c157f6bc547c3b2607)) + + Wires missing bitmap pixel formats (8/15/24bpp) into the session rendering + pipeline so bitmap updates at those depths are rendered instead of being + dropped, and adds fast-path palette update parsing to support 8bpp indexed + color sessions. + +- Add RemoteFX Progressive codec primitives ([#1196](https://github.com/Devolutions/IronRDP/issues/1196)) ([49099f0c31](https://github.com/Devolutions/IronRDP/commit/49099f0c3136c25b67801fb1b07f78542dc796de)) + + Add wire-format types for RemoteFX Progressive Codec (MS-RDPRFX + Progressive Extension) and the computational primitives required for progressive refinement. + +- Add progressive RFX decode and EGFX integration ([#1197](https://github.com/Devolutions/IronRDP/issues/1197)) ([a142799d1d](https://github.com/Devolutions/IronRDP/commit/a142799d1dcbdcd6546ec6e75173fbfe66f0ea67)) + +- Add progressive RFX server encode and mixed-codec frames ([#1198](https://github.com/Devolutions/IronRDP/issues/1198)) ([6d43d2692d](https://github.com/Devolutions/IronRDP/commit/6d43d2692d206b7557f722f294d3e51d7eac8ab1)) + +- Add ClearCodec bitmap compression codec ([#1174](https://github.com/Devolutions/IronRDP/issues/1174)) ([059ca902a5](https://github.com/Devolutions/IronRDP/commit/059ca902a5518113163042225bc5d2088869933a)) + +### Bug Fixes + +- Fix pixel format handling in bitmap decoders ([#1101](https://github.com/Devolutions/IronRDP/issues/1101)) ([75863245ab](https://github.com/Devolutions/IronRDP/commit/75863245ab376f15e35c00df434860c93b123633)) + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.6.0...ironrdp-graphics-v0.7.0)] - 2025-12-18 ### Added diff --git a/crates/ironrdp-graphics/Cargo.toml b/crates/ironrdp-graphics/Cargo.toml index 53c6fa0f68..a708c384b9 100644 --- a/crates/ironrdp-graphics/Cargo.toml +++ b/crates/ironrdp-graphics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-graphics" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "RDP image processing primitives" edition.workspace = true @@ -20,8 +20,8 @@ doctest = false bit_field = "0.10" bitflags = "2.11" bitvec = "1.0" -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["std"] } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["std"] } # public byteorder = "1.5" # TODO: remove num-derive.workspace = true # TODO: remove num-traits.workspace = true # TODO: remove diff --git a/crates/ironrdp-input/CHANGELOG.md b/crates/ironrdp-input/CHANGELOG.md index 21122b4aa7..b7efe4be9d 100644 --- a/crates/ironrdp-input/CHANGELOG.md +++ b/crates/ironrdp-input/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.5.0...ironrdp-input-v0.6.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.8 + ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.1.3...ironrdp-input-v0.2.0)] - 2025-03-12 ### Build @@ -13,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.1.2...ironrdp-input-v0.1.3)] - 2025-03-12 ### Build @@ -27,7 +32,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.1.0...ironrdp-input-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-input/Cargo.toml b/crates/ironrdp-input/Cargo.toml index aae8c19e90..dfdd3ceb77 100644 --- a/crates/ironrdp-input/Cargo.toml +++ b/crates/ironrdp-input/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-input" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "Utilities to manage and build RDP input packets" edition.workspace = true @@ -17,7 +17,7 @@ doctest = false test = false [dependencies] -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public bitvec = "1.0" smallvec = "1.15" diff --git a/crates/ironrdp-mstsgu/Cargo.toml b/crates/ironrdp-mstsgu/Cargo.toml index d1f9a02343..2fb0cd53d7 100644 --- a/crates/ironrdp-mstsgu/Cargo.toml +++ b/crates/ironrdp-mstsgu/Cargo.toml @@ -29,8 +29,8 @@ futures-util = "0.3" http-body-util = { version = "0.1" } hyper-util = { version = "0.1", features = ["tokio"] } hyper = { version = "1.9", features = ["client", "http1"] } -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["std"] } -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["std"] } +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } log = "0.4" tokio-tungstenite = { version = "0.29" } diff --git a/crates/ironrdp-pdu/CHANGELOG.md b/crates/ironrdp-pdu/CHANGELOG.md index ba7ae8cdbf..f3c710c792 100644 --- a/crates/ironrdp-pdu/CHANGELOG.md +++ b/crates/ironrdp-pdu/CHANGELOG.md @@ -6,6 +6,84 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.7.0...ironrdp-pdu-v0.8.0)] - 2026-05-27 + +### Features + +- Add Initiate Multitransport Request/Response PDU types ([#1091](https://github.com/Devolutions/IronRDP/issues/1091)) ([5a50f4099b](https://github.com/Devolutions/IronRDP/commit/5a50f4099b8f8173c5c067089a0d372402dbb52d)) + + Add MultitransportRequestPdu and MultitransportResponsePdu types for the + sideband UDP transport bootstrapping PDUs defined in MS-RDPBCGR + 2.2.15.1 and 2.2.15.2. Needed to decode/encode the IO channel messages that + initiate UDP transport setup. + +- Add Auto-Detect Request and Response PDU types ([#1168](https://github.com/Devolutions/IronRDP/issues/1168)) ([6e5f08a1b9](https://github.com/Devolutions/IronRDP/commit/6e5f08a1b95f69b9d8182a75298b74aaf829ac39)) + +- [**breaking**] Route auto-detect PDUs through ShareDataPdu dispatch ([#1176](https://github.com/Devolutions/IronRDP/issues/1176)) ([e5f2f36e96](https://github.com/Devolutions/IronRDP/commit/e5f2f36e96dfb2036236c99a1ee83c5a36bf281f)) + + Added Share Data PDU dispatch support for auto-detect PDUs, improving compatibility with Windows servers. + +- Complete pixel format support for bitmap updates ([#1134](https://github.com/Devolutions/IronRDP/issues/1134)) ([a6b41093ce](https://github.com/Devolutions/IronRDP/commit/a6b41093ce4ece081d2538c157f6bc547c3b2607)) + + Wires missing bitmap pixel formats (8/15/24bpp) into the session rendering + pipeline so bitmap updates at those depths are rendered instead of being + dropped, and adds fast-path palette update parsing to support 8bpp indexed + color sessions. + +- Add RemoteFX Progressive codec primitives ([#1196](https://github.com/Devolutions/IronRDP/issues/1196)) ([49099f0c31](https://github.com/Devolutions/IronRDP/commit/49099f0c3136c25b67801fb1b07f78542dc796de)) + + Add wire-format types for RemoteFX Progressive Codec (MS-RDPRFX + Progressive Extension) and the computational primitives required for progressive refinement. + +- Handle slow-path graphics and pointer updates ([#1132](https://github.com/Devolutions/IronRDP/issues/1132)) ([9383380292](https://github.com/Devolutions/IronRDP/commit/938338029290f1be82a7f784d544bb77ac797aeb)) + + Adds support for slow-path graphics and pointer updates to IronRDP, fixing connectivity issues with servers like XRDP that use slow-path output instead of fast-path. The implementation parses slow-path framing headers and routes the inner payload structures through the existing fast-path processing pipeline by extracting shared bitmap and pointer processing methods. + +- Add progressive RFX decode and EGFX integration ([#1197](https://github.com/Devolutions/IronRDP/issues/1197)) ([a142799d1d](https://github.com/Devolutions/IronRDP/commit/a142799d1dcbdcd6546ec6e75173fbfe66f0ea67)) + +- Add ClearCodec bitmap compression codec ([#1174](https://github.com/Devolutions/IronRDP/issues/1174)) ([059ca902a5](https://github.com/Devolutions/IronRDP/commit/059ca902a5518113163042225bc5d2088869933a)) + +### Bug Fixes + +- [**breaking**] Remove unused legacy error types ([#1268](https://github.com/Devolutions/IronRDP/issues/1268)) ([df0bf9c69d](https://github.com/Devolutions/IronRDP/commit/df0bf9c69d88febaf6b82c479fdc7dcafe226567)) + + Remove GccError, McsError, RdpError, SecurityDataError, + ClusterDataError, NetworkDataError, CoreDataError, InputEventError, + ClientInfoError, CapabilitySetsError, SessionError, and ChannelError. + All encode/decode functions had already been migrated to use + DecodeResult/EncodeResult from ironrdp-core, leaving these error types + as dead code. + +- Accept short Server Deactivate All PDU ([485d6c2f8d](https://github.com/Devolutions/IronRDP/commit/485d6c2f8d6f95bb06ca14cbfa4c56a27abbad0e)) + + Some servers (XRDP, older Windows) send a Deactivate All PDU without + the sourceDescriptor field. The decode previously required at least 3 + bytes, which caused a hard failure during deactivation-reactivation + sequences with these servers. + + Treat the sourceDescriptor as optional: if the remaining data is + shorter than the fixed part size, return successfully without + reading the field. FreeRDP handles this the same way. + +- Correct ShareDataHeader uncompressedLength calculation ([#1148](https://github.com/Devolutions/IronRDP/issues/1148)) ([c2688f464d](https://github.com/Devolutions/IronRDP/commit/c2688f464d8cbf239d35e5b43538195b1870eed8)) + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +- [**breaking**] Remove ironrdp-egfx duplicates from ironrdp-pdu ([#1303](https://github.com/Devolutions/IronRDP/issues/1303)) ([491b91fd2f](https://github.com/Devolutions/IronRDP/commit/491b91fd2f33235e4b31dea5c4a215e67f734179)) + +- Cover BitmapCacheV3 in CapabilitySet encoder ([#1313](https://github.com/Devolutions/IronRDP/issues/1313)) ([a71567e35e](https://github.com/Devolutions/IronRDP/commit/a71567e35e47a6eba8493c00933e0b66e0c63d5b)) + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.5.0...ironrdp-pdu-v0.6.0)] - 2025-08-29 ### Features diff --git a/crates/ironrdp-pdu/Cargo.toml b/crates/ironrdp-pdu/Cargo.toml index 64609c631e..17ba0dcf4e 100644 --- a/crates/ironrdp-pdu/Cargo.toml +++ b/crates/ironrdp-pdu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-pdu" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "RDP PDU encoding and decoding" edition.workspace = true @@ -26,8 +26,8 @@ arbitrary = ["alloc", "dep:arbitrary", "bitflags/arbitrary"] [dependencies] bitflags = "2.11" -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["std"] } # public -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["std"] } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public arbitrary = { version = "1", features = ["derive"], optional = true } tap = "1" diff --git a/crates/ironrdp-rdpdr-native/CHANGELOG.md b/crates/ironrdp-rdpdr-native/CHANGELOG.md index 301fa404b2..44d0c5c699 100644 --- a/crates/ironrdp-rdpdr-native/CHANGELOG.md +++ b/crates/ironrdp-rdpdr-native/CHANGELOG.md @@ -6,6 +6,21 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.5.0...ironrdp-rdpdr-native-v0.6.0)] - 2026-05-27 + +### Bug Fixes + +- Model CreateDisposition as enum instead of bitflags ([#1145](https://github.com/Devolutions/IronRDP/issues/1145)) ([c4f87aa417](https://github.com/Devolutions/IronRDP/commit/c4f87aa417e83c9cf6d1550c877ea3facb2f9a59)) + + CreateDisposition values (FILE_SUPERSEDE through FILE_OVERWRITE_IF) are + mutually exclusive integers 0 through 5, not combinable bit flags. + Modeling them with the bitflags macro causes subtle correctness issues. + +### Build + +- Bump nix from 0.30.1 to 0.31.1 ([#1085](https://github.com/Devolutions/IronRDP/issues/1085)) ([e92135dc0d](https://github.com/Devolutions/IronRDP/commit/e92135dc0d46bb3217ad26fcb82651c29e9c43c4)) + + ## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.4.0...ironrdp-rdpdr-native-v0.5.0)] - 2025-12-18 diff --git a/crates/ironrdp-rdpdr-native/Cargo.toml b/crates/ironrdp-rdpdr-native/Cargo.toml index 4880898716..e81db959d6 100644 --- a/crates/ironrdp-rdpdr-native/Cargo.toml +++ b/crates/ironrdp-rdpdr-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpdr-native" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "Native RDPDR static channel backend implementations for IronRDP" edition.workspace = true @@ -17,9 +17,9 @@ doctest = false test = false [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.5" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6" } # public nix = { version = "0.31", features = ["fs", "dir"] } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-rdpdr/CHANGELOG.md b/crates/ironrdp-rdpdr/CHANGELOG.md index e61a8251c6..c0ffb67ae4 100644 --- a/crates/ironrdp-rdpdr/CHANGELOG.md +++ b/crates/ironrdp-rdpdr/CHANGELOG.md @@ -6,6 +6,37 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.5.0...ironrdp-rdpdr-v0.6.0)] - 2026-05-27 + +### Features + +- Notify RdpdrBackend of 'User Logged On' Messages ([#1211](https://github.com/Devolutions/IronRDP/issues/1211)) ([1a09dbaca9](https://github.com/Devolutions/IronRDP/commit/1a09dbaca9dd5d35025ee50aaa645100222be189)) + +- Add Web RDPDR virtual printer support ([#1230](https://github.com/Devolutions/IronRDP/issues/1230)) ([14b1cef9cb](https://github.com/Devolutions/IronRDP/commit/14b1cef9cbbd0d8ef5e1fc8c73a3003a5e9f9bc2)) + + Adds RDPDR virtual printer redirection for web sessions, enabling the web client to announce a redirected printer, receive server print jobs over RDPDR, and deliver completed PostScript jobs to a browser callback. + +### Bug Fixes + +- Model CreateDisposition as enum instead of bitflags ([#1145](https://github.com/Devolutions/IronRDP/issues/1145)) ([c4f87aa417](https://github.com/Devolutions/IronRDP/commit/c4f87aa417e83c9cf6d1550c877ea3facb2f9a59)) + + CreateDisposition values (FILE_SUPERSEDE through FILE_OVERWRITE_IF) are + mutually exclusive integers 0 through 5, not combinable bit flags. + Modeling them with the bitflags macro causes subtle correctness issues. + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.4.1...ironrdp-rdpdr-v0.5.0)] - 2025-12-18 ### Bug Fixes diff --git a/crates/ironrdp-rdpdr/Cargo.toml b/crates/ironrdp-rdpdr/Cargo.toml index e216fa2059..55ad161bd3 100644 --- a/crates/ironrdp-rdpdr/Cargo.toml +++ b/crates/ironrdp-rdpdr/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpdr" -version = "0.5.0" +version = "0.6.0" readme = "README.md" description = "RDPDR channel implementation." edition.workspace = true @@ -17,10 +17,10 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public tracing = { version = "0.1", features = ["log"] } bitflags = "2.11" diff --git a/crates/ironrdp-rdpeusb/Cargo.toml b/crates/ironrdp-rdpeusb/Cargo.toml index 802cc95428..c344824072 100644 --- a/crates/ironrdp-rdpeusb/Cargo.toml +++ b/crates/ironrdp-rdpeusb/Cargo.toml @@ -21,8 +21,8 @@ default = [] std = [] [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["alloc"] } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public ironrdp-str = { path = "../ironrdp-str", version = "0.1" } [lints] diff --git a/crates/ironrdp-rdpsnd-native/CHANGELOG.md b/crates/ironrdp-rdpsnd-native/CHANGELOG.md index 7a41635ce4..8c0601c577 100644 --- a/crates/ironrdp-rdpsnd-native/CHANGELOG.md +++ b/crates/ironrdp-rdpsnd-native/CHANGELOG.md @@ -6,6 +6,19 @@ 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). +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.5.0...ironrdp-rdpsnd-native-v0.6.0)] - 2026-05-27 + +### Bug Fixes + +- Allocate Opus PCM buffer as Vec to avoid alignment panic ([#1256](https://github.com/Devolutions/IronRDP/issues/1256)) ([905a148604](https://github.com/Devolutions/IronRDP/commit/905a148604e7bac67cdcb2e915e3cacd29693f57)) + +### Build + +- Bump cpal from 0.16.0 to 0.17.1 ([#1071](https://github.com/Devolutions/IronRDP/issues/1071)) ([71245d58cc](https://github.com/Devolutions/IronRDP/commit/71245d58ccfb35dcc403628000ac2649b4bf9697)) + +- Bump opus2 from 0.3.3 to 0.4.0 ([#1204](https://github.com/Devolutions/IronRDP/issues/1204)) ([1eaf333057](https://github.com/Devolutions/IronRDP/commit/1eaf333057bec13778d78be2b2e71ca429733ee9)) + + ## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.4.0...ironrdp-rdpsnd-native-v0.4.1)] - 2025-09-24 ### Build diff --git a/crates/ironrdp-rdpsnd-native/Cargo.toml b/crates/ironrdp-rdpsnd-native/Cargo.toml index 928b543f6c..c4c02f6eb3 100644 --- a/crates/ironrdp-rdpsnd-native/Cargo.toml +++ b/crates/ironrdp-rdpsnd-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpsnd-native" -version = "0.5.0" +version = "0.6.0" description = "Native RDPSND static channel backend implementations for IronRDP" edition.workspace = true rust-version = "1.89" @@ -23,7 +23,7 @@ opus = ["dep:opus2", "dep:bytemuck"] anyhow = "1" bytemuck = { version = "1.24", optional = true } cpal = "0.17" -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.7" } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8" } # public opus2 = { version = "0.4", optional = true, features = ["bundled"] } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-rdpsnd/CHANGELOG.md b/crates/ironrdp-rdpsnd/CHANGELOG.md index d8a6796a1f..2b3db77634 100644 --- a/crates/ironrdp-rdpsnd/CHANGELOG.md +++ b/crates/ironrdp-rdpsnd/CHANGELOG.md @@ -6,6 +6,32 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.7.0...ironrdp-rdpsnd-v0.8.0)] - 2026-05-27 + +### Bug Fixes + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +- Handle AudioFormat renegotiation in Ready state ([#1164](https://github.com/Devolutions/IronRDP/issues/1164)) ([2fe6fd0424](https://github.com/Devolutions/IronRDP/commit/2fe6fd04244a7031a19af5a321bdf44308f6df2d)) + + Sometimes Windows Server re-sends `SNDC_FORMATS` during Ready state + (e.g., after mute/unmute in remote browser). Previously this hit the + wildcard branch, entering Stop and permanently killing audio. + + Add an `AudioFormat` arm in Ready state to close the current stream and + restart negotiation. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.4.0...ironrdp-rdpsnd-v0.5.0)] - 2025-05-27 ### Features @@ -50,7 +76,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New required method `get_formats` for the `RdpsndClientHandler` trait (#661) ([ccf6348270](https://github.com/Devolutions/IronRDP/commit/ccf63482706ecfbbdc6038028ea2ee086d0e3640)) - ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.1.1...ironrdp-rdpsnd-v0.2.0)] - 2025-01-28 ### Features @@ -64,7 +89,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.1.0...ironrdp-rdpsnd-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-rdpsnd/Cargo.toml b/crates/ironrdp-rdpsnd/Cargo.toml index df356c16ac..6aeaac9d7d 100644 --- a/crates/ironrdp-rdpsnd/Cargo.toml +++ b/crates/ironrdp-rdpsnd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpsnd" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "RDPSND static channel for audio output implemented as described in MS-RDPEA" edition.workspace = true @@ -23,9 +23,9 @@ std = [] [dependencies] bitflags = "2.11" tracing = { version = "0.1", features = ["log"] } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["alloc"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public [lints] workspace = true diff --git a/crates/ironrdp-server/CHANGELOG.md b/crates/ironrdp-server/CHANGELOG.md index 916740d66a..3547bdb699 100644 --- a/crates/ironrdp-server/CHANGELOG.md +++ b/crates/ironrdp-server/CHANGELOG.md @@ -6,6 +6,123 @@ 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). +## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.10.0...ironrdp-server-v0.11.0)] - 2026-05-27 + +### Features + +- Add clipboard data locking methods ([#1064](https://github.com/Devolutions/IronRDP/issues/1064)) ([58c3df84bb](https://github.com/Devolutions/IronRDP/commit/58c3df84bb9cafc8669315834cead35a71483c34)) + + Per MS-RDPECLIP sections 2.2.4.6 and 2.2.4.7, the Local + Clipboard Owner may lock the Shared Clipboard Owner's clipboard data before + requesting file contents to ensure data stability during multi-request transfers. + + This enables server implementations to safely request file data from + clients when handling clipboard paste operations. + +- Add request_file_contents method ([#1065](https://github.com/Devolutions/IronRDP/issues/1065)) ([c30fc35a28](https://github.com/Devolutions/IronRDP/commit/c30fc35a28d6218603c1662e98e8b3053bea3aa5)) + + Per MS-RDPECLIP section 2.2.5.3, the Local Clipboard Owner + sends File Contents Request PDU to retrieve file data from the Shared + Clipboard Owner during paste operations. + + This enables server implementations to request file contents from + clients, completing the bidirectional file transfer capability. + +- Add SendFileContentsResponse message variant ([#1066](https://github.com/Devolutions/IronRDP/issues/1066)) ([25f81337aa](https://github.com/Devolutions/IronRDP/commit/25f81337aa494af9a21f55f12ec27fd946465cbe)) + + Adds `SendFileContentsResponse` to `ClipboardMessage` enum, enabling + clipboard backends to signal when file data is ready to send via + `submit_file_contents()`. + + This provides the message-based interface pattern used consistently by + server implementations for clipboard operations. + +- Expose client display size to RdpServerDisplay ([#1083](https://github.com/Devolutions/IronRDP/issues/1083)) ([3cf570788d](https://github.com/Devolutions/IronRDP/commit/3cf570788d418ef0d83670c8581ddb61582237fe)) + + This allows the server implementation to handle the requested initial + client display size. The default implementation simply returns + `self.size()` so there's no change to existing behavior. + + Note that this method is also called during reactivations. + +- Add EGFX server integration with DVC bridge ([#1099](https://github.com/Devolutions/IronRDP/issues/1099)) ([4ba696c266](https://github.com/Devolutions/IronRDP/commit/4ba696c266c7065c93a691b9f818644fd471429b)) + +- Implement ECHO virtual channel ([#1109](https://github.com/Devolutions/IronRDP/issues/1109)) ([6f6496ad29](https://github.com/Devolutions/IronRDP/commit/6f6496ad29395099563d50417d6dfff623914ee6)) + +- Make run_connection generic over stream type ([#1181](https://github.com/Devolutions/IronRDP/issues/1181)) ([c30d853fa3](https://github.com/Devolutions/IronRDP/commit/c30d853fa34c2da02047b1dcb626f1009de2b61c)) + + Generalizes `RdpServer::run_connection` to accept arbitrary Tokio `AsyncRead + AsyncWrite` streams instead of a concrete `TcpStream`, enabling non-TCP transports (e.g., Unix sockets, VSOCK, in-process streams) to reuse the same server connection logic. + +- Add auto-detect RTT measurement ([#1177](https://github.com/Devolutions/IronRDP/issues/1177)) ([2515470fdb](https://github.com/Devolutions/IronRDP/commit/2515470fdb7187d20ee3fba8244b839efa4cbce4)) + + Adds server-side RTT measurement using the protocol-standard auto-detect + mechanism (MS-RDPBCGR 2.2.14). + +- IPv6 dual-stack and SO_REUSEADDR for run() ([#1187](https://github.com/Devolutions/IronRDP/issues/1187)) ([f10625cc80](https://github.com/Devolutions/IronRDP/commit/f10625cc806cc0ea9128c711df0dfd3ba8456b4f)) + +- Add ConnectionHandler trait for connection lifecycle hooks ([#1194](https://github.com/Devolutions/IronRDP/issues/1194)) ([5c08c7fe3d](https://github.com/Devolutions/IronRDP/commit/5c08c7fe3ded6f645cbddc53cdc0a02e8c45a037)) + +- Implement clipboard file transfer support ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Add end-to-end clipboard file transfer (upload and download) across the + CLIPRDR channel per MS-RDPECLIP. + +- Handle SuppressOutput / RefreshRectangle and expose state ([#1319](https://github.com/Devolutions/IronRDP/issues/1319)) ([aa7ff679b9](https://github.com/Devolutions/IronRDP/commit/aa7ff679b914dbbc9bfe137d7f4f26bea30d6323)) + +- Add pointer caching support to ironrdp-server ([1a6b4206d5](https://github.com/Devolutions/IronRDP/commit/1a6b4206d5f0fe3333da721adeaea3f7d2aa65cf)) + +### Bug Fixes + +- Make MultifragmentUpdate max_request_size configurable ([#1100](https://github.com/Devolutions/IronRDP/issues/1100)) ([d437b7e0b9](https://github.com/Devolutions/IronRDP/commit/d437b7e0b9a47f5b9246e24c76554df82f47670e)) + + The hardcoded `max_request_size` of 16,777,215 in the server's + MultifragmentUpdate capability causes mstsc to reject the connection (it + likely tries to allocate that buffer upfront). FreeRDP hit the same + problem and adjusted their value in FreeRDP/FreeRDP#1313. + + This adds a configurable `max_request_size` field to `RdpServerOptions` + with a default of 8 MB (matching what `ironrdp-connector` already uses + on the client side) and exposes it through the builder via + `with_max_request_size()`. + +- Tile bitmaps that exceed `MultifragmentUpdate` limit ([#1133](https://github.com/Devolutions/IronRDP/issues/1133)) ([db2f40b5b0](https://github.com/Devolutions/IronRDP/commit/db2f40b5b0af66a4c83e0e075e2814467c060b1d)) + + Split oversized dirty rects into horizontal strips that fit within `max_request_size` + before handing them to the bitmap encoder. + +- Skip bitmap updates that exceed bounds ([#1146](https://github.com/Devolutions/IronRDP/issues/1146)) ([2b97a95e6d](https://github.com/Devolutions/IronRDP/commit/2b97a95e6da8833e8a84e9f42960da91eee87cd6)) + + After a desktop resize, an RDP server can send a burst of bitmap updates + for the old resolution before its rendering pipeline has fully + transitioned to the new one. These updates reference coordinates beyond + the current image buffer in `DecodedImage`, causing index-out-of-bounds + panics in the `apply_*` methods. On the server side, the same stale + bitmaps can reach the encoder with dimensions exceeding the negotiated + desktop size, panicking in `NoneHandler::handle()`. + + This commit adds bounds checks at two levels: + - `DecodedImage::rect_fits()` guard at the entry of each `apply_*` + method, returning an empty rectangle when the update doesn't fit + - Encoder-level guard in `EncoderIter::next()` that drops + `BitmapUpdate`s exceeding the current desktop size + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +- Keep newest queued waves on per-batch overflow ([#1276](https://github.com/Devolutions/IronRDP/issues/1276)) ([6e8479763f](https://github.com/Devolutions/IronRDP/commit/6e8479763f2bcf0938bd4091e35fd5a322a787dd)) + +- Drop raw user_data dump from McsMessage::SendDataRequest debug log ([#1295](https://github.com/Devolutions/IronRDP/issues/1295)) ([424590ac76](https://github.com/Devolutions/IronRDP/commit/424590ac76f3f82de19b3d6d1aa7a0119f616fab)) + +### Build + +- Bump rayon from 1.11.0 to 1.12.0 ([#1235](https://github.com/Devolutions/IronRDP/issues/1235)) ([a5dab356e5](https://github.com/Devolutions/IronRDP/commit/a5dab356e5bc29cde2fdcd71b6d11fdf38a96a9f)) + + ## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.9.0...ironrdp-server-v0.10.0)] - 2025-12-18 ### Bug Fixes @@ -155,7 +272,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.3.1...ironrdp-server-v0.4.0)] - 2024-12-17 ### Features diff --git a/crates/ironrdp-server/Cargo.toml b/crates/ironrdp-server/Cargo.toml index a960c35832..dcd909d4b9 100644 --- a/crates/ironrdp-server/Cargo.toml +++ b/crates/ironrdp-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-server" -version = "0.10.0" +version = "0.11.0" readme = "README.md" description = "Extendable skeleton for implementing custom RDP servers" edition.workspace = true @@ -33,20 +33,20 @@ anyhow = "1.0" tokio = { version = "1", features = ["net", "macros", "sync", "rt"] } # public tokio-rustls = "0.26" # public async-trait = "0.1" -ironrdp-async = { path = "../ironrdp-async", version = "0.8" } -ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.5" } -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } +ironrdp-async = { path = "../ironrdp-async", version = "0.9" } +ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.6" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } ironrdp-egfx = { path = "../ironrdp-egfx", version = "0.1", optional = true } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.5" } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.5" } # public -ironrdp-echo = { path = "../ironrdp-echo", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5" } # public -ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.8", features = ["reqwest"] } -ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.8" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.7" } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.7" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6" } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.6" } # public +ironrdp-echo = { path = "../ironrdp-echo", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.9", features = ["reqwest"] } +ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.9" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } x509-cert = { version = "0.2", optional = true } rustls-pemfile = { version = "2.2", optional = true } diff --git a/crates/ironrdp-session/CHANGELOG.md b/crates/ironrdp-session/CHANGELOG.md index 90011784c1..5f258689a1 100644 --- a/crates/ironrdp-session/CHANGELOG.md +++ b/crates/ironrdp-session/CHANGELOG.md @@ -6,6 +6,86 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.8.0...ironrdp-session-v0.9.0)] - 2026-05-27 + +### Features + +- Dispatch multitransport PDUs on IO channel ([#1096](https://github.com/Devolutions/IronRDP/issues/1096)) ([7853e3cc6f](https://github.com/Devolutions/IronRDP/commit/7853e3cc6f26acaf3da000c6177ca3cef6ef85fd)) + + `decode_io_channel()` assumes all IO channel PDUs begin with + a`ShareControlHeader`. Multitransport Request PDUs use a + `BasicSecurityHeader` with `SEC_TRANSPORT_REQ` instead ([MS-RDPBCGR] + 2.2.15.1). + + This adds a peek-based dispatch: check the first `u16` + for`TRANSPORT_REQ`, decode as `MultitransportRequestPdu` if set, + otherwise fall through to the existing `decode_share_control()` path + unchanged. + + The new variant is propagated through `ProcessorOutput` and + 'ActiveStageOutput` so applications can handle multitransport requests. + Client and web consumers log the request (no UDP transport yet). + +- Add bulk compression and wire negotiation ([ebf5da5f33](https://github.com/Devolutions/IronRDP/commit/ebf5da5f3380a3355f6c95814d669f8190425ded)) + + - add ironrdp-bulk crate with MPPC/NCRUSH/XCRUSH, bitstream, benches, and metrics + - advertise compression in Client Info and plumb compression_type through connector + - decode compressed FastPath/ShareData updates using BulkCompressor + - update CLI to numeric compression flags (enabled by default, level 0-3) + - extend screenshot example with compression options and negotiated logging + - refresh tests, FFI/web configs, typos, and Cargo.lock + +- Complete pixel format support for bitmap updates ([#1134](https://github.com/Devolutions/IronRDP/issues/1134)) ([a6b41093ce](https://github.com/Devolutions/IronRDP/commit/a6b41093ce4ece081d2538c157f6bc547c3b2607)) + + Wires missing bitmap pixel formats (8/15/24bpp) into the session rendering + pipeline so bitmap updates at those depths are rendered instead of being + dropped, and adds fast-path palette update parsing to support 8bpp indexed + color sessions. + +- Handle Auto-Detect Request PDUs from server ([#1178](https://github.com/Devolutions/IronRDP/issues/1178)) ([4dcad09980](https://github.com/Devolutions/IronRDP/commit/4dcad09980e4f5354e4e435a134cc0956e2fcf9e)) + + Fixes a crash when the server sends Auto-Detect Request PDUs during an + active session. After #1176 added ShareDataPdu::AutoDetectReq routing, + these PDUs decode correctly but hit the catch-all error path in the x224 + processor: "unhandled PDU: Auto-Detect Request PDU". + +- Handle slow-path graphics and pointer updates ([#1132](https://github.com/Devolutions/IronRDP/issues/1132)) ([9383380292](https://github.com/Devolutions/IronRDP/commit/938338029290f1be82a7f784d544bb77ac797aeb)) + + Adds support for slow-path graphics and pointer updates to IronRDP, fixing connectivity issues with servers like XRDP that use slow-path output instead of fast-path. The implementation parses slow-path framing headers and routes the inner payload structures through the existing fast-path processing pipeline by extracting shared bitmap and pointer processing methods. + +### Bug Fixes + +- Fix pixel format handling in bitmap decoders ([#1101](https://github.com/Devolutions/IronRDP/issues/1101)) ([75863245ab](https://github.com/Devolutions/IronRDP/commit/75863245ab376f15e35c00df434860c93b123633)) + +- Handle row padding in uncompressed bitmap updates ([4262ae75ff](https://github.com/Devolutions/IronRDP/commit/4262ae75ffa5cb1fabb4ca07d598e33d855e8fdd)) + + Uncompressed bitmap data has rows padded to 4-byte boundaries per + [MS-RDPBCGR] 2.2.9.1.1.3.1.2.2, but the bitmap apply functions + expect tightly packed pixel data. Strip the per-row padding before + passing raw bitmap data to the apply functions. + + This fixes garbled bitmap rendering when connecting to servers that + send uncompressed bitmaps with non-aligned row widths, such as XRDP + at 16 bpp. + +- Skip bitmap updates that exceed bounds ([#1146](https://github.com/Devolutions/IronRDP/issues/1146)) ([2b97a95e6d](https://github.com/Devolutions/IronRDP/commit/2b97a95e6da8833e8a84e9f42960da91eee87cd6)) + + After a desktop resize, an RDP server can send a burst of bitmap updates + for the old resolution before its rendering pipeline has fully + transitioned to the new one. These updates reference coordinates beyond + the current image buffer in `DecodedImage`, causing index-out-of-bounds + panics in the `apply_*` methods. On the server side, the same stale + bitmaps can reach the encoder with dimensions exceeding the negotiated + desktop size, panicking in `NoneHandler::handle()`. + + This commit adds bounds checks at two levels: + - `DecodedImage::rect_fits()` guard at the entry of each `apply_*` + method, returning an empty rectangle when the update doesn't fit + - Encoder-level guard in `EncoderIter::next()` that drops + `BitmapUpdate`s exceeding the current desktop size + +- Propagate negotiated share_id to all outgoing ShareDataPdu ([#1147](https://github.com/Devolutions/IronRDP/issues/1147)) ([2b24e9664d](https://github.com/Devolutions/IronRDP/commit/2b24e9664dd05620ff63a24d092377477fdde863)) + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.7.0...ironrdp-session-v0.8.0)] - 2025-12-18 @@ -75,7 +155,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.0...ironrdp-session-v0.2.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-session/Cargo.toml b/crates/ironrdp-session/Cargo.toml index 1659a9974e..5be827b393 100644 --- a/crates/ironrdp-session/Cargo.toml +++ b/crates/ironrdp-session/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-session" -version = "0.8.0" +version = "0.9.0" readme = "README.md" description = "State machines to drive an RDP session" edition.workspace = true @@ -23,14 +23,14 @@ qoiz = ["dep:zstd-safe", "qoi"] [dependencies] ironrdp-bulk = { path = "../ironrdp-bulk", version = "0.1" } -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.8" } # public # TODO: at some point, this dependency could be removed (good for compilation speed) -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5" } # public -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.7" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["std"] } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.5" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public # TODO: at some point, this dependency could be removed (good for compilation speed) +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["std"] } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.6" } tracing = { version = "0.1", features = ["log"] } qoicoubeh = { version = "0.5", optional = true } zstd-safe = { version = "7.2", optional = true, features = ["std"] } diff --git a/crates/ironrdp-str/CHANGELOG.md b/crates/ironrdp-str/CHANGELOG.md new file mode 100644 index 0000000000..1bae5ef861 --- /dev/null +++ b/crates/ironrdp-str/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-str-v0.1.0...ironrdp-str-v0.1.1)] - 2026-05-27 + +### Build + +- Update dependencies. diff --git a/crates/ironrdp-str/Cargo.toml b/crates/ironrdp-str/Cargo.toml index 4ce44e26f5..03a4f60189 100644 --- a/crates/ironrdp-str/Cargo.toml +++ b/crates/ironrdp-str/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-str" -version = "0.1.0" +version = "0.1.1" description = "Typed wire-aware string primitives for RDP protocol fields" edition.workspace = true rust-version = "1.89" @@ -18,7 +18,7 @@ alloc = ["ironrdp-core/alloc", "bytemuck/extern_crate_alloc"] [dependencies] bytemuck = { version = "1", default-features = false } -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } [lints] workspace = true diff --git a/crates/ironrdp-svc/CHANGELOG.md b/crates/ironrdp-svc/CHANGELOG.md index 7e650f78ca..c5cd24820f 100644 --- a/crates/ironrdp-svc/CHANGELOG.md +++ b/crates/ironrdp-svc/CHANGELOG.md @@ -6,6 +6,17 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.6.0...ironrdp-svc-v0.7.0)] - 2026-05-27 + +### Features + +- Add SvcMessage::encode_unframed_pdu for headerless encoding ([#1093](https://github.com/Devolutions/IronRDP/issues/1093)) ([a21378e16a](https://github.com/Devolutions/IronRDP/commit/a21378e16a3a5af36428ba9a226b08acc5113eb6)) + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.4.0...ironrdp-svc-v0.4.1)] - 2025-06-27 ### Features @@ -19,7 +30,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump bitflags from 2.9.0 to 2.9.1 in the patch group across 1 directory (#792) ([87ed315bc2](https://github.com/Devolutions/IronRDP/commit/87ed315bc28fdd2dcfea89b052fa620a7e346e5a)) - ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.2.0...ironrdp-svc-v0.3.0)] - 2025-03-12 ### Build @@ -27,7 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.1.3...ironrdp-svc-v0.2.0)] - 2025-03-12 ### Build @@ -41,7 +50,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.1.1...ironrdp-svc-v0.1.2)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-svc/Cargo.toml b/crates/ironrdp-svc/Cargo.toml index e529b57e31..b17dcce05d 100644 --- a/crates/ironrdp-svc/Cargo.toml +++ b/crates/ironrdp-svc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-svc" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "IronRDP traits to implement RDP static virtual channels" edition.workspace = true @@ -21,8 +21,8 @@ default = [] std = [] [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", features = ["alloc", "std"] } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc", "std"] } # public bitflags = "2.11" [lints] diff --git a/crates/ironrdp-tls/CHANGELOG.md b/crates/ironrdp-tls/CHANGELOG.md index 2aaa1c2585..9d3124ab6a 100644 --- a/crates/ironrdp-tls/CHANGELOG.md +++ b/crates/ironrdp-tls/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.2.0...ironrdp-tls-v0.2.1)] - 2026-05-27 + +### Build + +- Bump tokio from 1.50.0 to 1.52.1 ([#1219](https://github.com/Devolutions/IronRDP/issues/1219)) ([d3e673b455](https://github.com/Devolutions/IronRDP/commit/d3e673b455ec817df7590cef27e598c8517828ae)) ([#1223](https://github.com/Devolutions/IronRDP/issues/1223)) ([8bf140f49d](https://github.com/Devolutions/IronRDP/commit/8bf140f49d3bee952e395ffeb514a27c4725eb15)) + ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.1.4...ironrdp-tls-v0.2.0)] - 2025-12-18 ### Features diff --git a/crates/ironrdp-tls/Cargo.toml b/crates/ironrdp-tls/Cargo.toml index 4c0e49d709..578f3a745d 100644 --- a/crates/ironrdp-tls/Cargo.toml +++ b/crates/ironrdp-tls/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-tls" -version = "0.2.0" +version = "0.2.1" readme = "README.md" description = "TLS boilerplate common with most IronRDP clients" edition.workspace = true diff --git a/crates/ironrdp-tokio/CHANGELOG.md b/crates/ironrdp-tokio/CHANGELOG.md index db3bd881b7..b1b6a90704 100644 --- a/crates/ironrdp-tokio/CHANGELOG.md +++ b/crates/ironrdp-tokio/CHANGELOG.md @@ -6,6 +6,13 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.8.0...ironrdp-tokio-v0.9.0)] - 2026-05-27 + +### Build + +- [**breaking**] Upgrade sspi + + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.7.0...ironrdp-tokio-v0.8.0)] - 2025-12-18 ### Features @@ -92,7 +99,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.2.0...ironrdp-tokio-v0.2.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-tokio/Cargo.toml b/crates/ironrdp-tokio/Cargo.toml index e8dcbc391d..042ae76057 100644 --- a/crates/ironrdp-tokio/Cargo.toml +++ b/crates/ironrdp-tokio/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-tokio" -version = "0.8.0" +version = "0.9.0" readme = "README.md" description = "`Framed*` traits implementation above Tokio’s traits" edition.workspace = true @@ -23,8 +23,8 @@ reqwest-rustls-ring = ["reqwest", "reqwest?/rustls-tls-webpki-roots"] reqwest-native-tls = ["reqwest", "reqwest?/native-tls"] [dependencies] -ironrdp-async = { path = "../ironrdp-async", version = "0.8" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.8", optional = true } +ironrdp-async = { path = "../ironrdp-async", version = "0.9" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.9", optional = true } tokio = { version = "1", features = ["io-util"] } reqwest = { version = "0.12", default-features = false, features = ["http2", "system-proxy"], optional = true } url = { version = "2.5", optional = true } diff --git a/crates/ironrdp-viewer/Cargo.toml b/crates/ironrdp-viewer/Cargo.toml index f98870c1be..bec6730242 100644 --- a/crates/ironrdp-viewer/Cargo.toml +++ b/crates/ironrdp-viewer/Cargo.toml @@ -31,9 +31,9 @@ qoi = ["ironrdp-client/qoi"] qoiz = ["ironrdp-client/qoiz"] [dependencies] -ironrdp = { path = "../ironrdp", version = "0.14", features = ["input", "pdu"] } +ironrdp = { path = "../ironrdp", version = "0.15", features = ["input", "pdu"] } ironrdp-client = { path = "../ironrdp-client", version = "0.1", default-features = false } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.5" } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6" } ironrdp-cfg = { path = "../ironrdp-cfg" } ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } ironrdp-propertyset = { path = "../ironrdp-propertyset" } diff --git a/crates/ironrdp/CHANGELOG.md b/crates/ironrdp/CHANGELOG.md index 48a74967df..8cd1552b31 100644 --- a/crates/ironrdp/CHANGELOG.md +++ b/crates/ironrdp/CHANGELOG.md @@ -6,6 +6,12 @@ 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). +## [[0.15.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.14.0...ironrdp-v0.15.0)] - 2026-05-27 + +### Build + +- Update dependencies + ## [[0.14.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.13.0...ironrdp-v0.14.0)] - 2025-12-18 ### Build @@ -70,7 +76,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Inline documentation for re-exported items (#619) ([cff5c1a59c](https://github.com/Devolutions/IronRDP/commit/cff5c1a59cdc2da73cabcb675fcf2d85dc81fd68)) - ## [[0.7.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.7.1...ironrdp-v0.7.2)] - 2024-12-15 ### Documentation @@ -82,10 +87,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 workspace). - ## [[0.7.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.7.0...ironrdp-v0.7.1)] - 2024-12-14 ### Other - Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a)) - diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index 14d5173c71..86e2d63a33 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp" -version = "0.14.0" +version = "0.15.0" readme = "README.md" description = "A meta crate re-exporting IronRDP crates for convenience" edition.workspace = true @@ -40,25 +40,25 @@ qoiz = ["ironrdp-server?/qoiz", "ironrdp-pdu?/qoiz", "ironrdp-connector?/qoiz", __bench = ["ironrdp-server/__bench"] [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1", optional = true } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.7", optional = true } # public -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.5", optional = true } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.8", optional = true } # public -ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.8", optional = true } # public -ironrdp-session = { path = "../ironrdp-session", version = "0.8", optional = true } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.7", optional = true } # public -ironrdp-input = { path = "../ironrdp-input", version = "0.5", optional = true } # public -ironrdp-server = { path = "../ironrdp-server", version = "0.10", optional = true, features = ["helper"] } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.6", optional = true } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.5", optional = true } # public -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.5", optional = true } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.7", optional = true } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.5", optional = true } # public -ironrdp-echo = { path = "../ironrdp-echo", version = "0.1", optional = true } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", optional = true } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", optional = true } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6", optional = true } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.9", optional = true } # public +ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.9", optional = true } # public +ironrdp-session = { path = "../ironrdp-session", version = "0.9", optional = true } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8", optional = true } # public +ironrdp-input = { path = "../ironrdp-input", version = "0.6", optional = true } # public +ironrdp-server = { path = "../ironrdp-server", version = "0.11", optional = true, features = ["helper"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7", optional = true } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6", optional = true } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6", optional = true } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8", optional = true } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.6", optional = true } # public +ironrdp-echo = { path = "../ironrdp-echo", version = "0.2", optional = true } # public [dev-dependencies] -ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.8" } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.5" } +ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.9" } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6" } anyhow = "1" async-trait = "0.1" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 17b15d3aea..1f3b492544 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -57,15 +57,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64ct" -version = "1.8.1" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bit_field" @@ -108,9 +108,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.49" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -217,9 +217,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -237,9 +237,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flagset" @@ -249,9 +249,9 @@ checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -287,11 +287,11 @@ dependencies = [ [[package]] name = "ironrdp-bulk" -version = "0.1.0" +version = "0.1.1" [[package]] name = "ironrdp-cliprdr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitflags", "ironrdp-core", @@ -302,7 +302,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-format" -version = "0.1.4" +version = "0.2.0" dependencies = [ "ironrdp-core", "png", @@ -310,14 +310,14 @@ dependencies = [ [[package]] name = "ironrdp-core" -version = "0.1.5" +version = "0.2.0" dependencies = [ "ironrdp-error", ] [[package]] name = "ironrdp-displaycontrol" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -328,7 +328,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -351,7 +351,7 @@ dependencies = [ [[package]] name = "ironrdp-error" -version = "0.1.3" +version = "0.2.0" [[package]] name = "ironrdp-fuzz" @@ -381,7 +381,7 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.7.0" +version = "0.8.0" dependencies = [ "bit_field", "bitflags", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "ironrdp-pdu" -version = "0.7.0" +version = "0.8.0" dependencies = [ "bit_field", "bitflags", @@ -417,7 +417,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bitflags", "ironrdp-core", @@ -429,7 +429,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.7.0" +version = "0.8.0" dependencies = [ "bitflags", "ironrdp-core", @@ -440,7 +440,7 @@ dependencies = [ [[package]] name = "ironrdp-svc" -version = "0.6.0" +version = "0.7.0" dependencies = [ "bitflags", "ironrdp-core", @@ -459,9 +459,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" @@ -491,9 +491,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "minimal-lexical" @@ -562,15 +562,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs1" @@ -597,18 +597,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -653,9 +653,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "spki" @@ -669,9 +669,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -770,15 +770,15 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "version_check" @@ -788,18 +788,18 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ "wit-bindgen", ] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wyz" @@ -842,9 +842,9 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", From 3905d177f2417d0e810b437d7e3ee0a7e954c6cc Mon Sep 17 00:00:00 2001 From: Alex Yusiuk <55661041+RRRadicalEdward@users.noreply.github.com> Date: Fri, 29 May 2026 19:53:40 +0300 Subject: [PATCH 262/325] build(web): pass extra optimization flags for WASM release build (#1342) Co-authored-by: Alexandr Yusuk --- xtask/src/web.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xtask/src/web.rs b/xtask/src/web.rs index e94f1f6cc0..e86691373e 100644 --- a/xtask/src/web.rs +++ b/xtask/src/web.rs @@ -55,7 +55,8 @@ pub fn build(sh: &Shell, wasm_pack_dev: bool) -> anyhow::Result<()> { } else { let _env_guard = sh.push_env( "RUSTFLAGS", - "-Ctarget-feature=+simd128,+bulk-memory --cfg getrandom_backend=\"wasm_js\"", + "-Ctarget-feature=+simd128,+bulk-memory --cfg getrandom_backend=\"wasm_js\" + -Copt-level=s -Ccodegen-units=1 -Cllvm-args=-enable-dfa-jump-thread", ); run_cmd_in!(sh, IRONRDP_WEB_PATH, "wasm-pack build --target web")?; } From e3dc0e70d04253d0f881e051c653fb8cb6403281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:00:10 +0900 Subject: [PATCH 263/325] chore(release): prepare ironrdp-egfx release (#1355) --- crates/ironrdp-egfx/CHANGELOG.md | 2 +- crates/ironrdp-egfx/Cargo.toml | 1 - crates/ironrdp-server/CHANGELOG.md | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/ironrdp-egfx/CHANGELOG.md b/crates/ironrdp-egfx/CHANGELOG.md index 2a2ac65a4f..82f362a204 100644 --- a/crates/ironrdp-egfx/CHANGELOG.md +++ b/crates/ironrdp-egfx/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. 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] +## [0.1.0] - 2026-06-01 ### Added diff --git a/crates/ironrdp-egfx/Cargo.toml b/crates/ironrdp-egfx/Cargo.toml index 4d16f10f00..b829a17364 100644 --- a/crates/ironrdp-egfx/Cargo.toml +++ b/crates/ironrdp-egfx/Cargo.toml @@ -3,7 +3,6 @@ name = "ironrdp-egfx" version = "0.1.0" readme = "README.md" description = "Graphics pipeline dynamic channel extension implementation" -publish = false # TODO: publish edition.workspace = true license.workspace = true homepage.workspace = true diff --git a/crates/ironrdp-server/CHANGELOG.md b/crates/ironrdp-server/CHANGELOG.md index 3547bdb699..d8a6bc0790 100644 --- a/crates/ironrdp-server/CHANGELOG.md +++ b/crates/ironrdp-server/CHANGELOG.md @@ -6,7 +6,7 @@ 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). -## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.10.0...ironrdp-server-v0.11.0)] - 2026-05-27 +## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.10.0...ironrdp-server-v0.11.0)] - 2026-06-01 ### Features From cf51bdd1d5ba062132039f5ed6d7871e00af6412 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 1 Jun 2026 10:27:37 -0500 Subject: [PATCH 264/325] feat(egfx)!: surface total_frames_decoded on the frame-ack callback (#1345) --- crates/ironrdp-egfx/src/server.rs | 12 ++++++++++-- crates/ironrdp-testsuite-core/tests/egfx/server.rs | 6 +++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/ironrdp-egfx/src/server.rs b/crates/ironrdp-egfx/src/server.rs index 4ba3fe2517..dd1261b8ee 100644 --- a/crates/ironrdp-egfx/src/server.rs +++ b/crates/ironrdp-egfx/src/server.rs @@ -767,7 +767,14 @@ pub trait GraphicsPipelineHandler: Send { fn on_ready(&mut self, negotiated: &CapabilitySet); /// Called when a frame has been acknowledged by the client - fn on_frame_ack(&mut self, _frame_id: u32, _queue_depth: u32) {} + /// + /// `total_frames_decoded` is the client's running decoded-frame count + /// (MS-RDPEGFX 2.2.2.13), for decode-backlog flow control. + fn on_frame_ack(&mut self, frame_id: u32, queue_depth: u32, total_frames_decoded: u32) { + let _ = frame_id; + let _ = queue_depth; + let _ = total_frames_decoded; + } /// Called when QoE metrics are received from client (V10+) fn on_qoe_metrics(&mut self, _metrics: QoeMetrics) {} @@ -1685,7 +1692,8 @@ impl GraphicsPipelineServer { trace!(frame_id = pdu.frame_id, latency = ?rtt); } - self.handler.on_frame_ack(pdu.frame_id, queue_depth); + self.handler + .on_frame_ack(pdu.frame_id, queue_depth, pdu.total_frames_decoded); } fn handle_qoe_frame_acknowledge(&mut self, pdu: QoeFrameAcknowledgePdu) { diff --git a/crates/ironrdp-testsuite-core/tests/egfx/server.rs b/crates/ironrdp-testsuite-core/tests/egfx/server.rs index 086eba9cfa..71964e30c4 100644 --- a/crates/ironrdp-testsuite-core/tests/egfx/server.rs +++ b/crates/ironrdp-testsuite-core/tests/egfx/server.rs @@ -13,7 +13,7 @@ use ironrdp_egfx::server::{GraphicsPipelineHandler, GraphicsPipelineServer, QoeM struct TestHandler { ready_called: bool, negotiated: Option, - frame_acks: Vec<(u32, u32)>, + frame_acks: Vec<(u32, u32, u32)>, surfaces_created: Vec, surfaces_deleted: Vec, } @@ -38,8 +38,8 @@ impl GraphicsPipelineHandler for TestHandler { self.negotiated = Some(negotiated.clone()); } - fn on_frame_ack(&mut self, frame_id: u32, queue_depth: u32) { - self.frame_acks.push((frame_id, queue_depth)); + fn on_frame_ack(&mut self, frame_id: u32, queue_depth: u32, total_frames_decoded: u32) { + self.frame_acks.push((frame_id, queue_depth, total_frames_decoded)); } fn on_qoe_metrics(&mut self, _metrics: QoeMetrics) {} From 54af8f677fde726e2734f7bb1b451f3099d63532 Mon Sep 17 00:00:00 2001 From: clintcan Date: Mon, 1 Jun 2026 23:39:43 +0800 Subject: [PATCH 265/325] feat(nscodec): introduce ironrdp-nscodec crate + opt-in server integration (#1332) Adds an opt-in implementation of the legacy RDP NSCodec encoder as a standalone crate, and wires it into `ironrdp-server` behind a feature flag so servers can serve NSCodec-only clients (notably macOS Microsoft Remote Desktop / Windows App) without default-build behavior changes. --- Cargo.lock | 8 + crates/ironrdp-nscodec/Cargo.toml | 30 +++ crates/ironrdp-nscodec/LICENSE-APACHE | 1 + crates/ironrdp-nscodec/LICENSE-MIT | 1 + crates/ironrdp-nscodec/README.md | 30 +++ crates/ironrdp-nscodec/src/encoder.rs | 311 +++++++++++++++++++++++ crates/ironrdp-nscodec/src/lib.rs | 5 + crates/ironrdp-server/Cargo.toml | 5 + crates/ironrdp-server/src/encoder/mod.rs | 60 +++++ crates/ironrdp-server/src/server.rs | 15 ++ 10 files changed, 466 insertions(+) create mode 100644 crates/ironrdp-nscodec/Cargo.toml create mode 120000 crates/ironrdp-nscodec/LICENSE-APACHE create mode 120000 crates/ironrdp-nscodec/LICENSE-MIT create mode 100644 crates/ironrdp-nscodec/README.md create mode 100644 crates/ironrdp-nscodec/src/encoder.rs create mode 100644 crates/ironrdp-nscodec/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7eb95b3b3e..333222ccb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2723,6 +2723,13 @@ dependencies = [ "uuid", ] +[[package]] +name = "ironrdp-nscodec" +version = "0.1.0" +dependencies = [ + "ironrdp-graphics", +] + [[package]] name = "ironrdp-pdu" version = "0.8.0" @@ -2845,6 +2852,7 @@ dependencies = [ "ironrdp-echo", "ironrdp-egfx", "ironrdp-graphics", + "ironrdp-nscodec", "ironrdp-pdu", "ironrdp-rdpsnd", "ironrdp-svc", diff --git a/crates/ironrdp-nscodec/Cargo.toml b/crates/ironrdp-nscodec/Cargo.toml new file mode 100644 index 0000000000..c7d1287601 --- /dev/null +++ b/crates/ironrdp-nscodec/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ironrdp-nscodec" +version = "0.1.0" +readme = "README.md" +description = "NSCodec ([MS-RDPNSC]) implementation for IronRDP" +publish = false # TODO: publish +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +doctest = false +# test = false # FIXME: turn off and keep tests in testsuite crates + +[features] +# Encoder is opt-in. Default-disabled because consumers (notably +# `ironrdp-server`) hide NSCodec behind their own feature gate too, so it +# should never be pulled in incidentally. +default = [] +encoder = ["dep:ironrdp-graphics"] + +[dependencies] +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8", optional = true } # public when `encoder` is on + +[lints] +workspace = true diff --git a/crates/ironrdp-nscodec/LICENSE-APACHE b/crates/ironrdp-nscodec/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/crates/ironrdp-nscodec/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/ironrdp-nscodec/LICENSE-MIT b/crates/ironrdp-nscodec/LICENSE-MIT new file mode 120000 index 0000000000..b2cfbdc7b0 --- /dev/null +++ b/crates/ironrdp-nscodec/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/ironrdp-nscodec/README.md b/crates/ironrdp-nscodec/README.md new file mode 100644 index 0000000000..71c483cf40 --- /dev/null +++ b/crates/ironrdp-nscodec/README.md @@ -0,0 +1,30 @@ +# ironrdp-nscodec + +NSCodec ([MS-RDPNSC]) implementation for IronRDP. + +NSCodec is a legacy bitmap codec used in the RDP "Surface Bits" command path. It +predates RemoteFX but remains the only legacy codec advertised by the macOS +Microsoft Remote Desktop / Windows App client's bitmap codec list, so servers +wanting non-raw bitmap delivery to that client need it. + +## Feature Flags + +- **`encoder`** -- Opt-in; pulls in the server-side encoder + (`ironrdp_nscodec::encoder::encode`) and `ironrdp-graphics` for the + `PixelFormat` input enum. + +With no features (`default-features = false`), the crate compiles to an empty +shell — enable `encoder` to get the actual code. + +## Status + +Encoder side only. Implements the codec defined in MS-RDPNSC §3.1.5: + +1. RGB → YCoCg color-space conversion (lossy on chroma when CLL > 0). +2. Per-plane RLE compression (custom MS-RDPNSC byte-level RLE). +3. 20-byte frame header + concatenated Y, Co, Cg, A planes. + +Chroma subsampling (`ChromaSubsamplingLevel = 1`, 4:2:0) is not yet +implemented; the encoder always emits `ChromaSubsamplingLevel = 0`. + +[MS-RDPNSC]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpnsc/68df0993-2c44-4d57-8aef-cdab1c1c43a8 diff --git a/crates/ironrdp-nscodec/src/encoder.rs b/crates/ironrdp-nscodec/src/encoder.rs new file mode 100644 index 0000000000..64808dd91e --- /dev/null +++ b/crates/ironrdp-nscodec/src/encoder.rs @@ -0,0 +1,311 @@ +//! MS-RDPNSC encoder. +//! +//! Implements the codec defined in MS-RDPNSC §3.1.5: +//! 1. RGB → YCoCg color-space conversion (lossy on chroma when CLL > 0). +//! 2. Optional 4:2:0 chroma subsampling — **not implemented**. The encoder +//! always emits `ChromaSubsamplingLevel = 0` (full-resolution chroma). +//! 3. Per-plane RLE compression (custom MS-RDPNSC byte-level RLE). +//! 4. 20-byte frame header + concatenated Y, Co, Cg, A planes. +//! +//! The encoded byte stream is suitable to drop into the `bitmapData` of a +//! `TS_BITMAP_DATA_EX` carried by a `SurfaceBitsPdu` (MS-RDPBCGR §2.2.9.2.1); +//! that PDU plumbing belongs to the consumer (typically `ironrdp-server`). + +use ironrdp_graphics::image_processing::PixelFormat; + +/// 0xFF in the third byte of a run header signals "long run; read u32 LE next." +const RLE_LONG_ESCAPE: u8 = 0xFF; + +/// Encode an in-memory bitmap as an NSCodec frame. +/// +/// # Parameters +/// +/// - `data` — pixel buffer in `format`, with `stride` bytes per row. +/// - `width`, `height` — dimensions in pixels (both must be non-zero). +/// - `stride` — bytes between the start of consecutive rows. Must be at least +/// `width * format.bytes_per_pixel()`. +/// - `format` — one of the eight 32-bpp `PixelFormat` variants. Note that the +/// input pixel alpha is **ignored**: the encoder always emits a fully opaque +/// (`0xFF`) alpha plane regardless of the source alpha byte (desktop captures +/// are opaque, and a zero/premultiplied source alpha would otherwise blend to +/// black on the client). Callers must not rely on alpha being preserved. +/// - `color_loss_level` — must be 1..=7 per MS-RDPNSC. Higher = smaller output +/// but more chroma loss. The value passed here MUST match what was advertised +/// in the `NsCodec` capability set, or the client will decode against the +/// wrong shift and chroma will look wrong. We `debug_assert!` `>= 1`; at +/// CLL=0 the un-shifted Co/Cg values exceed the `i8` plane range and are +/// clamped (see `rgb_to_ycocg`), so the frame still decodes but with severe +/// chroma clipping — callers should either clamp upstream or arrange for the +/// capability advertisement to never send CLL=0. +/// +/// # Panics +/// +/// Debug-asserts `color_loss_level >= 1` and `color_loss_level <= 7`, and that +/// `stride`/`data` are large enough for `width`×`height`. In release builds the +/// function does not panic on a bad CLL: the frame is still emitted, but a +/// non-conformant CLL decodes with visibly incorrect chroma (CLL=0 → heavy +/// chroma clipping; CLL>7 → chroma shifted past zero precision). +pub fn encode( + data: &[u8], + width: u16, + height: u16, + stride: usize, + format: PixelFormat, + color_loss_level: u8, +) -> Vec { + #![allow(clippy::similar_names)] // y_plane / co_plane / cg_plane / a_plane match the spec naming. + + debug_assert!(color_loss_level >= 1, "MS-RDPNSC CLL must be in 1..=7"); + debug_assert!(color_loss_level <= 7, "MS-RDPNSC CLL must be in 1..=7"); + + let w = usize::from(width); + let h = usize::from(height); + let pixels = w * h; + let cll = i32::from(color_loss_level); + let bpp = usize::from(format.bytes_per_pixel()); + + debug_assert!( + stride >= w * bpp, + "stride ({stride}) must be at least width * bytes_per_pixel ({})", + w * bpp + ); + debug_assert!( + data.len() >= h.saturating_sub(1) * stride + w * bpp, + "data ({} bytes) too small for {width}x{height} at stride {stride}", + data.len() + ); + + let mut y_plane = Vec::with_capacity(pixels); + let mut co_plane = Vec::with_capacity(pixels); + let mut cg_plane = Vec::with_capacity(pixels); + let mut a_plane = Vec::with_capacity(pixels); + + // Surface Bits clients consume the bitmap data in bottom-up row order + // (inherited from the legacy compressed bitmap convention in + // MS-RDPBCGR §2.2.9.1.1.3.1.2.2, which `TS_BITMAP_DATA_EX` also follows). + // Top-down inputs (e.g. macOS ScreenCaptureKit) need to be flipped here, + // otherwise each dirty rect is rendered upside-down inside its bounding + // box. + for row in (0..h).rev() { + let row_off = row * stride; + for col in 0..w { + let off = row_off + col * bpp; + let p = &data[off..off + bpp]; + let (r, g, b, _a) = extract_rgba(format, p); + let (y, co, cg) = rgb_to_ycocg(r, g, b, cll); + y_plane.push(y); + co_plane.push(co); + cg_plane.push(cg); + // Desktop captures are always opaque; the source `A` byte can be + // zero on macOS (premultiplied / unused), and NSCodec clients + // treat the alpha plane as actual blending — alpha=0 makes + // everything transparent and the canvas renders as black. + a_plane.push(0xFF); + } + } + + let y_rle = rle_encode(&y_plane); + let co_rle = rle_encode(&co_plane); + let cg_rle = rle_encode(&cg_plane); + let a_rle = rle_encode(&a_plane); + + let plane_len = |rle: &[u8]| -> u32 { + // RLE expansion is bounded by the plane size (worst case is unbounded + // literals = `pixels` bytes plus a constant), which is at most + // `u16::MAX * u16::MAX` = ~4.3 GB — comfortably u32. A u32::MAX cap is + // defensive for the impossible-in-practice overflow case. + u32::try_from(rle.len()).unwrap_or(u32::MAX) + }; + + let body_len = y_rle.len() + co_rle.len() + cg_rle.len() + a_rle.len(); + let mut out = Vec::with_capacity(20 + body_len); + // 20-byte fixed header per MS-RDPNSC §2.2.1.x. + out.extend_from_slice(&plane_len(&y_rle).to_le_bytes()); + out.extend_from_slice(&plane_len(&co_rle).to_le_bytes()); + out.extend_from_slice(&plane_len(&cg_rle).to_le_bytes()); + out.extend_from_slice(&plane_len(&a_rle).to_le_bytes()); + out.push(color_loss_level); + out.push(0); // ChromaSubsamplingLevel = 0 (no chroma subsampling). + out.push(0); // Reserved (2 bytes, MUST be 0). + out.push(0); + out.extend_from_slice(&y_rle); + out.extend_from_slice(&co_rle); + out.extend_from_slice(&cg_rle); + out.extend_from_slice(&a_rle); + + out +} + +/// Pull (R, G, B, A) out of a 4-byte pixel in the given format. +#[inline] +fn extract_rgba(fmt: PixelFormat, p: &[u8]) -> (u8, u8, u8, u8) { + match fmt { + PixelFormat::ARgb32 | PixelFormat::XRgb32 => (p[1], p[2], p[3], p[0]), + PixelFormat::ABgr32 | PixelFormat::XBgr32 => (p[3], p[2], p[1], p[0]), + PixelFormat::BgrA32 | PixelFormat::BgrX32 => (p[2], p[1], p[0], p[3]), + PixelFormat::RgbA32 | PixelFormat::RgbX32 => (p[0], p[1], p[2], p[3]), + } +} + +/// RGB → (Y, Co, Cg) using the FreeRDP formulation (which Microsoft clients +/// decode against). Y is unsigned 0..=253; Co and Cg are signed values stored +/// in the `u8` bit pattern of their `i8` form. +/// +/// Note on Co/Cg storage range: at the advertised CLL=3 typical of real +/// deployments, Co and Cg fit comfortably in `i8`. At CLL<3 they can overflow +/// — see the encoder doc-comment. +#[inline] +fn rgb_to_ycocg(r: u8, g: u8, b: u8, cll: i32) -> (u8, u8, u8) { + #![allow(clippy::similar_names)] // co / cg / co_raw / cg_raw match the spec. + + let ri = i32::from(r); + let gi = i32::from(g); + let bi = i32::from(b); + // y ∈ [0, 253] for r,g,b ∈ [0, 255] — always fits in u8. + let y_i32 = (ri >> 2) + (gi >> 1) + (bi >> 2); + let y = u8::try_from(y_i32.clamp(0, 255)).expect("clamped to [0, 255]"); + // At CLL ≥ 1 (debug-asserted by caller), co and cg ∈ [-128, 127] and fit + // in i8; storing as u8 preserves the bit pattern. + let co_raw = (ri - bi) >> cll; + let cg_raw = (-(ri >> 1) + gi - (bi >> 1)) >> cll; + let co = i8::try_from(co_raw.clamp(i32::from(i8::MIN), i32::from(i8::MAX))) + .expect("clamped to i8 range") + .cast_unsigned(); + let cg = i8::try_from(cg_raw.clamp(i32::from(i8::MIN), i32::from(i8::MAX))) + .expect("clamped to i8 range") + .cast_unsigned(); + (y, co, cg) +} + +/// MS-RDPNSC RLE. +/// +/// A run is introduced by a value byte appearing twice in succession; the +/// third byte is either `runlength - 2` (0..=253, runs of 2..=255) or `0xFF` +/// (long-run escape) followed by a 32-bit LE runlength. A single occurrence +/// of a value is a plain literal. +/// +/// **The last 4 bytes of each plane are copied raw, *not* RLE-encoded** — +/// this matches the FreeRDP reference encoder, which Microsoft NSCodec +/// clients are written against. The decoder unconditionally reads the last +/// 4 bytes of compressed plane data as raw output, so emitting RLE there +/// makes the entire frame undecodable. This convention is implementation- +/// derived, not in the MS-RDPNSC text. +fn rle_encode(plane: &[u8]) -> Vec { + let n = plane.len(); + if n <= 4 { + // Plane too small for any RLE — emit raw. + return plane.to_vec(); + } + let body_end = n - 4; + let mut out = Vec::with_capacity(n); + let mut i = 0; + while i < body_end { + let v = plane[i]; + let mut run = 1usize; + while i + run < body_end && plane[i + run] == v { + run += 1; + // Run length must fit in u32 for the long-run wire encoding. + // Cap here to avoid overflow on the eventual `to_le_bytes()`. + if u32::try_from(run).is_err() { + break; + } + } + if run == 1 { + out.push(v); + } else if run <= 255 { + out.push(v); + out.push(v); + // `run` is in 2..=255 so `run - 2` fits in u8. + out.push(u8::try_from(run - 2).expect("run <= 255 implies run-2 fits in u8")); + } else { + out.push(v); + out.push(v); + out.push(RLE_LONG_ESCAPE); + out.extend_from_slice(&u32::try_from(run).unwrap_or(u32::MAX).to_le_bytes()); + } + i += run; + } + // Last 4 bytes of the plane are copied raw, by spec/convention. + out.extend_from_slice(&plane[body_end..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rle_short_input_is_raw() { + // Inputs of <= 4 bytes can't have a raw 4-byte tail AND a body, so + // the whole thing is emitted as-is. + assert_eq!(rle_encode(&[7]), vec![7]); + assert_eq!(rle_encode(&[7, 8]), vec![7, 8]); + assert_eq!(rle_encode(&[1, 2, 3, 4]), vec![1, 2, 3, 4]); + } + + #[test] + fn rle_no_runs_in_body() { + // 5-byte plane: body is 1 byte (plane[0]); tail is 4 bytes raw. + // plane[0]=1 is a literal; remaining 4 bytes copied raw. + assert_eq!(rle_encode(&[1, 2, 2, 2, 2]), vec![1, 2, 2, 2, 2]); + } + + #[test] + fn rle_short_run_in_body_with_raw_tail() { + // 6 bytes of 7 -> body is plane[0..2] = [7, 7] -> short run of 2. + // Tail: plane[2..6] = [7, 7, 7, 7]. + let plane = vec![7u8; 6]; + assert_eq!(rle_encode(&plane), vec![7, 7, 0, 7, 7, 7, 7]); + } + + #[test] + fn rle_long_run_in_body_with_raw_tail() { + // 1000 bytes of 4 -> body 996 bytes of 4 -> long run, then 4 raw. + let plane = vec![4u8; 1000]; + let mut want = vec![4, 4, RLE_LONG_ESCAPE]; + want.extend_from_slice(&996u32.to_le_bytes()); + want.extend_from_slice(&[4, 4, 4, 4]); + assert_eq!(rle_encode(&plane), want); + } + + #[test] + fn ycocg_white_is_white() { + // White (255,255,255) should give Y near 254 and Co/Cg near 0. + let (y, co, cg) = rgb_to_ycocg(255, 255, 255, 3); + assert_eq!(y, 253); + assert_eq!(co, 0); + assert_eq!(cg, 0); + } + + #[test] + fn ycocg_black_is_zero() { + let (y, co, cg) = rgb_to_ycocg(0, 0, 0, 3); + assert_eq!(y, 0); + assert_eq!(co, 0); + assert_eq!(cg, 0); + } + + #[test] + fn encode_emits_expected_header_size() { + #![allow(clippy::similar_names)] // y_len / co_len / cg_len / a_len mirror the plane naming. + + // 2x2 solid red BgrA32. Each plane will be RLE-encoded; verify the + // 20-byte header layout and that the total length is header + sum + // of plane lengths. + let data = vec![0, 0, 255, 0xFF, 0, 0, 255, 0xFF, 0, 0, 255, 0xFF, 0, 0, 255, 0xFF]; + let out = encode(&data, 2, 2, 8, PixelFormat::BgrA32, 3); + assert!(out.len() >= 20, "header at minimum"); + let read_u32 = |slice: &[u8]| -> usize { + usize::try_from(u32::from_le_bytes(slice.try_into().expect("4 bytes"))) + .expect("usize >= u32 on supported targets") + }; + let y_len = read_u32(&out[0..4]); + let co_len = read_u32(&out[4..8]); + let cg_len = read_u32(&out[8..12]); + let a_len = read_u32(&out[12..16]); + assert_eq!(out[16], 3, "CLL stored in header"); + assert_eq!(out[17], 0, "ChromaSubsamplingLevel = 0"); + assert_eq!(&out[18..20], &[0, 0], "reserved = 0"); + assert_eq!(out.len(), 20 + y_len + co_len + cg_len + a_len); + } +} diff --git a/crates/ironrdp-nscodec/src/lib.rs b/crates/ironrdp-nscodec/src/lib.rs new file mode 100644 index 0000000000..574fc2d446 --- /dev/null +++ b/crates/ironrdp-nscodec/src/lib.rs @@ -0,0 +1,5 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] + +#[cfg(feature = "encoder")] +pub mod encoder; diff --git a/crates/ironrdp-server/Cargo.toml b/crates/ironrdp-server/Cargo.toml index dcd909d4b9..3cba1a878d 100644 --- a/crates/ironrdp-server/Cargo.toml +++ b/crates/ironrdp-server/Cargo.toml @@ -23,6 +23,10 @@ rayon = ["dep:rayon"] qoi = ["dep:qoicoubeh", "ironrdp-pdu/qoi"] qoiz = ["dep:zstd-safe", "qoi", "ironrdp-pdu/qoiz"] egfx = ["dep:ironrdp-egfx"] +# Opt-in NSCodec encoder. Off by default so consumers that don't need a legacy +# bitmap codec fallback (e.g., RemoteFX-only or H.264-capable clients) don't +# pay the extra build cost. +nscodec = ["dep:ironrdp-nscodec"] # Internal (PRIVATE!) features used to aid testing. # Don't rely on these whatsoever. They may disappear at any time. @@ -37,6 +41,7 @@ ironrdp-async = { path = "../ironrdp-async", version = "0.9" } ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.6" } ironrdp-core = { path = "../ironrdp-core", version = "0.2" } ironrdp-egfx = { path = "../ironrdp-egfx", version = "0.1", optional = true } +ironrdp-nscodec = { path = "../ironrdp-nscodec", version = "0.1", optional = true, features = ["encoder"] } ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6" } # public diff --git a/crates/ironrdp-server/src/encoder/mod.rs b/crates/ironrdp-server/src/encoder/mod.rs index 93b1cea079..647558915b 100644 --- a/crates/ironrdp-server/src/encoder/mod.rs +++ b/crates/ironrdp-server/src/encoder/mod.rs @@ -51,6 +51,9 @@ pub(crate) struct UpdateEncoderCodecs { qoi: Option, #[cfg(feature = "qoiz")] qoiz: Option, + /// `(codec_id, color_loss_level)` from the negotiated NsCodec capability. + #[cfg(feature = "nscodec")] + nscodec: Option<(u8, u8)>, } impl UpdateEncoderCodecs { @@ -62,6 +65,8 @@ impl UpdateEncoderCodecs { qoi: None, #[cfg(feature = "qoiz")] qoiz: None, + #[cfg(feature = "nscodec")] + nscodec: None, } } @@ -81,6 +86,14 @@ impl UpdateEncoderCodecs { pub(crate) fn set_qoiz(&mut self, qoiz: Option) { self.qoiz = qoiz } + + /// Record the negotiated NsCodec codec id and color-loss level so the + /// encoder selection path can build an `NsCodecHandler` for this session. + #[cfg(feature = "nscodec")] + #[cfg_attr(feature = "__bench", visibility::make(pub))] + pub(crate) fn set_nscodec(&mut self, nscodec: Option<(u8, u8)>) { + self.nscodec = nscodec + } } impl Default for UpdateEncoderCodecs { @@ -128,6 +141,17 @@ impl UpdateEncoder { remotefx: Some((algo, id)), .. } => BitmapUpdater::RemoteFx(RemoteFxHandler::new(algo, id, desktop_size)), + // NSCodec is the lowest-priority codec because it predates + // RemoteFX and produces larger output. It's relevant mainly + // for clients (notably macOS Microsoft Remote Desktop / + // Windows App) whose legacy bitmap-codec list advertises + // only NSCodec — those clients would otherwise fall through + // to raw/RLE BitmapUpdate at much higher bandwidth. + #[cfg(feature = "nscodec")] + UpdateEncoderCodecs { + nscodec: Some((id, cll)), + .. + } => BitmapUpdater::NsCodec(NsCodecHandler::new(id, cll)), _ => BitmapUpdater::None(NoneHandler), } } else { @@ -431,6 +455,8 @@ enum BitmapUpdater { Qoi(QoiHandler), #[cfg(feature = "qoiz")] Qoiz(QoizHandler), + #[cfg(feature = "nscodec")] + NsCodec(NsCodecHandler), } impl BitmapUpdater { @@ -443,6 +469,8 @@ impl BitmapUpdater { Self::Qoi(up) => up.handle(bitmap), #[cfg(feature = "qoiz")] Self::Qoiz(up) => up.handle(bitmap), + #[cfg(feature = "nscodec")] + Self::NsCodec(up) => up.handle(bitmap), } } @@ -648,6 +676,38 @@ impl BitmapUpdateHandler for QoizHandler { } } +#[cfg(feature = "nscodec")] +#[derive(Clone, Debug)] +struct NsCodecHandler { + codec_id: u8, + color_loss_level: u8, +} + +#[cfg(feature = "nscodec")] +impl NsCodecHandler { + fn new(codec_id: u8, color_loss_level: u8) -> Self { + Self { + codec_id, + color_loss_level, + } + } +} + +#[cfg(feature = "nscodec")] +impl BitmapUpdateHandler for NsCodecHandler { + fn handle(&mut self, bitmap: &BitmapUpdate) -> Result { + let data = ironrdp_nscodec::encoder::encode( + &bitmap.data, + bitmap.width.get(), + bitmap.height.get(), + bitmap.stride.get(), + bitmap.format, + self.color_loss_level, + ); + set_surface(bitmap, self.codec_id, &data) + } +} + #[cfg(feature = "qoi")] fn qoi_encode(bitmap: &BitmapUpdate) -> Result> { use ironrdp_graphics::image_processing::PixelFormat::*; diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 23149c8b61..810bf6276c 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -137,6 +137,14 @@ impl RdpServerOptions { .iter() .any(|codec| matches!(codec.property, CodecProperty::QoiZ)) } + + #[cfg(feature = "nscodec")] + fn has_nscodec(&self) -> bool { + self.codecs + .0 + .iter() + .any(|codec| matches!(codec.property, CodecProperty::NsCodec(_))) + } } #[derive(Clone)] @@ -1111,6 +1119,13 @@ impl RdpServer { update_codecs.set_remotefx(Some((caps.entropy_bits, codec.id))); } } + #[cfg(feature = "nscodec")] + CodecProperty::NsCodec(client_ns) if self.opts.has_nscodec() => { + // Re-use the client's confirmed color-loss + // level so the server encodes at the same + // shift the client decodes against. + update_codecs.set_nscodec(Some((codec.id, client_ns.color_loss_level))); + } CodecProperty::NsCodec(_) => (), #[cfg(feature = "qoi")] CodecProperty::Qoi if self.opts.has_qoi() => { From 4e11a1761750bb706f5c3cef370589d0eb63fc45 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 1 Jun 2026 10:48:59 -0500 Subject: [PATCH 266/325] fix(graphics): bound ZGFX compressor hash table size (#1344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounds the ZGFX compressor's hash table to prevent O(n·table_size) per-frame compaction on incompressible payloads (e.g., already-encoded H.264). Previously, `compact_hash_table` only halved per-prefix position lists without reducing prefix count, so high-entropy input kept the table above the cap and triggered compaction on every literal byte. The fix evicts whole least-recently-seen prefixes down to a low watermark (half the cap), amortizing compaction to O(1) per byte while preserving reachable matches (distance is already capped at `MAX_MATCH_DISTANCE`). --- .../ironrdp-graphics/src/zgfx/compressor.rs | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-graphics/src/zgfx/compressor.rs b/crates/ironrdp-graphics/src/zgfx/compressor.rs index 9b793c6a79..f9c11585d0 100644 --- a/crates/ironrdp-graphics/src/zgfx/compressor.rs +++ b/crates/ironrdp-graphics/src/zgfx/compressor.rs @@ -26,6 +26,10 @@ const MAX_POSITIONS_PER_PREFIX: usize = 32; /// Trigger hash table compaction when entry count exceeds this const MAX_HASH_TABLE_ENTRIES: usize = 50_000; +/// Compaction evicts down to this low watermark, so it runs at most once per +/// `MAX_HASH_TABLE_ENTRIES - COMPACT_TARGET_ENTRIES` inserted prefixes +const COMPACT_TARGET_ENTRIES: usize = MAX_HASH_TABLE_ENTRIES / 2; + /// ZGFX compressor maintaining a 2.5 MB history buffer and prefix hash table. pub struct Compressor { history: Vec, @@ -133,7 +137,15 @@ impl Compressor { } } - /// Halve stored positions per prefix to bound memory. + /// Halve stored positions per prefix, then evict whole prefixes down to + /// `COMPACT_TARGET_ENTRIES` when the table is over the cap. + /// + /// INVARIANT: the table holds at most `MAX_HASH_TABLE_ENTRIES` prefixes on + /// return. Incompressible input yields a near-unique prefix per byte, so + /// trimming position lists alone never lowers the prefix count; without + /// evicting whole prefixes the table would stay above the threshold and the + /// caller would re-run compaction on every literal byte at O(table) cost. + /// Evicting to a lower watermark amortizes that cost to O(1) per byte. fn compact_hash_table(&mut self) { for positions in self.match_table.values_mut() { if positions.len() > MAX_POSITIONS_PER_PREFIX / 2 { @@ -142,6 +154,25 @@ impl Compressor { } } self.match_table.retain(|_, positions| !positions.is_empty()); + + if self.match_table.len() <= COMPACT_TARGET_ENTRIES { + return; + } + + // Keep the most-recently-seen prefixes; older ones point further back + // than a fresh match can reach, and distance is capped at + // MAX_MATCH_DISTANCE regardless. Each history position belongs to a + // single prefix, so these newest positions are distinct and the cutoff + // retains exactly COMPACT_TARGET_ENTRIES entries. + let mut newest: Vec = self + .match_table + .values() + .map(|positions| positions.last().copied().unwrap_or(0)) + .collect(); + let cutoff_index = newest.len() - COMPACT_TARGET_ENTRIES; + let cutoff = *newest.select_nth_unstable(cutoff_index).1; + self.match_table + .retain(|_, positions| positions.last().is_some_and(|&pos| pos >= cutoff)); } /// Search hash table for the longest match at `input[pos..]`. @@ -460,6 +491,38 @@ mod tests { assert_eq!(output, data); } + #[test] + fn compress_high_entropy_round_trips_and_bounds_table() { + use super::super::Decompressor; + + // A near-unique 3-byte prefix per byte is the compactor's worst case: + // trimming per-prefix position lists frees nothing, so without evicting + // whole prefixes the table grows past MAX_HASH_TABLE_ENTRIES and + // compaction re-runs on every literal byte at O(table) cost. A + // deterministic LCG makes the stream exceed the entry cap reproducibly. + let mut state: u32 = 0x1234_5678; + let data: Vec = core::iter::repeat_with(|| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + u8::try_from(state >> 24).unwrap() + }) + .take(100_000) + .collect(); + + let mut compressor = Compressor::new(); + let compressed = compressor.compress(&data).unwrap(); + + let mut decompressor = Decompressor::new(); + let mut output = Vec::new(); + decompressor.decompress_segment(&compressed, &mut output).unwrap(); + assert_eq!(output, data); + + assert!( + compressor.match_table.len() <= MAX_HASH_TABLE_ENTRIES, + "hash table must stay bounded, got {} entries", + compressor.match_table.len() + ); + } + #[test] fn bit_writer_basic() { let mut writer = BitWriter::new(); From 7894d9f093db3c80f7358af8e0d8beb18964ce45 Mon Sep 17 00:00:00 2001 From: clintcan Date: Tue, 2 Jun 2026 00:04:07 +0800 Subject: [PATCH 267/325] docs(rdpsnd): document RdpsndServerHandler::start wFormatNo contract (#1343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Rustdoc documentation to `RdpsndServerHandler`, focusing on the contract for `start()`’s `Option` return value so implementers correctly compute `wFormatNo` for Wave/Wave2 PDUs. --- crates/ironrdp-rdpsnd/src/server.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/ironrdp-rdpsnd/src/server.rs b/crates/ironrdp-rdpsnd/src/server.rs index 2914c16a90..dd0026deee 100644 --- a/crates/ironrdp-rdpsnd/src/server.rs +++ b/crates/ironrdp-rdpsnd/src/server.rs @@ -28,11 +28,37 @@ pub enum RdpsndServerMessage { Error(Box), } +/// Handler for the server side of the Audio Output Virtual Channel (`RDPSND`). +/// +/// Implementations supply the list of audio formats the server offers, decide +/// which format to use once the client replies, and produce the audio waves to +/// stream (via [`RdpsndServer::wave`]). pub trait RdpsndServerHandler: Send + core::fmt::Debug { + /// The audio formats the server advertises in the Server Audio Formats and + /// Version PDU (MS-RDPEA 2.2.2.1). fn get_formats(&self) -> &[pdu::AudioFormat]; + /// Called once the client has replied with the formats it accepts + /// (`client_format`, the Client Audio Formats and Version PDU). Returns the + /// `wFormatNo` to stamp on every subsequent Wave/Wave2 PDU, or [`None`] if + /// no offered format is acceptable (no audio is then streamed). + /// + /// **The returned index addresses `client_format.formats` — the formats the + /// client just echoed back — NOT the server's own [`get_formats`] list.** + /// The client resolves each wave's format as `ClientFormats[wFormatNo]` + /// against the list *it* sent, and a compliant client rejects any + /// `wFormatNo >= client_format.formats.len()`, silently dropping all audio. + /// The client's list is its accepted subset of the server's formats, so the + /// two lists generally differ in both length and ordering; an index into + /// [`get_formats`] only happens to work when the chosen format sits at the + /// same position in both. Pick the format you intend to send, then return + /// its position within `client_format.formats`. + /// + /// [`get_formats`]: RdpsndServerHandler::get_formats fn start(&mut self, client_format: &ClientAudioFormatPdu) -> Option; + /// Called when the audio stream is torn down (e.g. the client closed the + /// channel or the session ended). fn stop(&mut self); } From 8a9ee6268ccdb5704c2bb60bed6d2adf57761427 Mon Sep 17 00:00:00 2001 From: clintcan Date: Tue, 2 Jun 2026 00:13:35 +0800 Subject: [PATCH 268/325] fix(server): emit RGB-channel QOI for opaque captures so ironrdp-session can decode (#1335) --- crates/ironrdp-server/src/encoder/mod.rs | 27 +++++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/ironrdp-server/src/encoder/mod.rs b/crates/ironrdp-server/src/encoder/mod.rs index 647558915b..d3f83c9e92 100644 --- a/crates/ironrdp-server/src/encoder/mod.rs +++ b/crates/ironrdp-server/src/encoder/mod.rs @@ -711,15 +711,26 @@ impl BitmapUpdateHandler for NsCodecHandler { #[cfg(feature = "qoi")] fn qoi_encode(bitmap: &BitmapUpdate) -> Result> { use ironrdp_graphics::image_processing::PixelFormat::*; + // Map every 4-byte input — whether it nominally has an alpha byte or + // an "X" filler — to the 3-channel-output `*x` variant of + // `RawChannels`. The qoi crate selects `Channels::Rgb` vs + // `Channels::Rgba` for the QOI header from this enum: `*x` and `*r/g/b` + // produce `Rgb`; `*a` produces `Rgba`. The `ironrdp-session` NSCodec- + // free decode path in `fast_path.rs::qoi_apply` only supports + // `Channels::Rgb` and explicitly drops `Channels::Rgba` frames with + // `WARN: Unsupported RGBA QOI data`, so the previous "honest" mapping + // (`BgrA32 -> Bgra`, etc.) produced output that no IronRDP client + // could decode — every QOI session rendered a blank screen. + // + // Server-side bitmap captures are functionally opaque (the alpha byte + // is either always 0xFF or treated as filler), so discarding it is + // safe and matches what every successful legacy bitmap path + // already does. let raw_channels = match bitmap.format { - ARgb32 => qoi::RawChannels::Argb, - XRgb32 => qoi::RawChannels::Xrgb, - ABgr32 => qoi::RawChannels::Abgr, - XBgr32 => qoi::RawChannels::Xbgr, - BgrA32 => qoi::RawChannels::Bgra, - BgrX32 => qoi::RawChannels::Bgrx, - RgbA32 => qoi::RawChannels::Rgba, - RgbX32 => qoi::RawChannels::Rgbx, + ARgb32 | XRgb32 => qoi::RawChannels::Xrgb, + ABgr32 | XBgr32 => qoi::RawChannels::Xbgr, + BgrA32 | BgrX32 => qoi::RawChannels::Bgrx, + RgbA32 | RgbX32 => qoi::RawChannels::Rgbx, }; let enc = qoi::EncoderBuilder::new(&bitmap.data, bitmap.width.get().into(), bitmap.height.get().into()) .stride(bitmap.stride.get()) From 479a13aa49478e333ccdc4c8fdf03aa4f36d2cac Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 1 Jun 2026 11:37:30 -0500 Subject: [PATCH 269/325] feat(egfx): cascade Arbitrary derives across ironrdp-egfx public PDU types (#1334) --- Cargo.lock | 1 + crates/ironrdp-egfx/Cargo.toml | 2 + crates/ironrdp-egfx/src/pdu/avc.rs | 30 +++++++++++++++ crates/ironrdp-egfx/src/pdu/cmd.rs | 54 +++++++++++++++++++++++++++ crates/ironrdp-egfx/src/pdu/common.rs | 3 ++ xtask/src/features.rs | 8 ++++ 6 files changed, 98 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 333222ccb7..608bf37f97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2636,6 +2636,7 @@ dependencies = [ name = "ironrdp-egfx" version = "0.1.0" dependencies = [ + "arbitrary", "bit_field", "bitflags 2.11.1", "ironrdp-core", diff --git a/crates/ironrdp-egfx/Cargo.toml b/crates/ironrdp-egfx/Cargo.toml index b829a17364..bd7ef2ee48 100644 --- a/crates/ironrdp-egfx/Cargo.toml +++ b/crates/ironrdp-egfx/Cargo.toml @@ -16,6 +16,7 @@ doctest = false # test = false # FIXME: turn off and keep tests in testsuite crates [dependencies] +arbitrary = { version = "1", features = ["derive"], optional = true } bit_field = "0.10" bitflags = "2.11" ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public @@ -26,6 +27,7 @@ openh264 = { version = "0.9", optional = true, default-features = false } tracing = { version = "0.1", features = ["log"] } [features] +arbitrary = ["dep:arbitrary", "bitflags/arbitrary", "ironrdp-pdu/arbitrary"] openh264 = ["dep:openh264"] openh264-bundled = ["openh264", "openh264/source"] openh264-libloading = ["openh264", "openh264/libloading"] diff --git a/crates/ironrdp-egfx/src/pdu/avc.rs b/crates/ironrdp-egfx/src/pdu/avc.rs index 625a409865..d5039bfa4a 100644 --- a/crates/ironrdp-egfx/src/pdu/avc.rs +++ b/crates/ironrdp-egfx/src/pdu/avc.rs @@ -15,6 +15,21 @@ pub struct QuantQuality { pub quality: u8, } +// Manual `Arbitrary` impl: the encoder packs `quantization_parameter` into bits 0..6 +// via `set_bits`, which panics when the value exceeds 6 bits. Mask the field to its +// wire-allowed range so fuzz inputs always round-trip through `Encode`. The other +// fields use their full type range. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for QuantQuality { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Self { + quantization_parameter: u.arbitrary::()? & 0x3F, // 6 bits + progressive: u.arbitrary()?, + quality: u.arbitrary()?, + }) + } +} + impl QuantQuality { const NAME: &'static str = "GfxQuantQuality"; @@ -58,6 +73,7 @@ impl<'de> Decode<'de> for QuantQuality { } } +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Clone, PartialEq, Eq)] pub struct Avc420BitmapStream<'a> { pub rectangles: Vec, @@ -152,6 +168,19 @@ bitflags! { } } +// Manual `Arbitrary` impl: the encoder packs `encoding.bits()` into 2 bits via +// `set_bits(30..32, ...)` on the Avc444BitmapStream stream-info field. The bitflag +// otherwise accepts any u8 value (via `const _ = !0`), so the bitflags-crate-provided +// derive would generate values that exceed the 2-bit wire range and panic the encoder. +// Mask to 2 bits. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for Encoding { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Self::from_bits_retain(u.arbitrary::()? & 0x03)) + } +} + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct Avc444BitmapStream<'a> { pub encoding: Encoding, @@ -263,6 +292,7 @@ impl<'de> Decode<'de> for Avc444BitmapStream<'de> { /// assert_eq!(region.left, 0); /// assert_eq!(region.right, 1919); /// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct Avc420Region { /// Left edge of the region (inclusive) diff --git a/crates/ironrdp-egfx/src/pdu/cmd.rs b/crates/ironrdp-egfx/src/pdu/cmd.rs index 19dded7059..c61c09e660 100644 --- a/crates/ironrdp-egfx/src/pdu/cmd.rs +++ b/crates/ironrdp-egfx/src/pdu/cmd.rs @@ -44,6 +44,7 @@ const RESET_GRAPHICS_PDU_SIZE: usize = 340 - GfxPdu::FIXED_PART_SIZE; /// Display Pipeline Virtual Channel message (PDU prefixed with `RDPGFX_HEADER`) /// /// INVARIANTS: size of encoded inner PDU is always less than `u32::MAX - Self::FIXED_PART_SIZE` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum GfxPdu { @@ -312,6 +313,7 @@ impl<'de> Decode<'de> for GfxPdu { /// (one-past-end), matching FreeRDP and the Windows reference clients. /// /// [2.2.2.1]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Clone, PartialEq, Eq)] pub struct WireToSurface1Pdu { pub surface_id: u16, @@ -387,6 +389,7 @@ impl<'a> Decode<'a> for WireToSurface1Pdu { /// 2.2.2.2 RDPGFX_WIRE_TO_SURFACE_PDU_2 /// /// [2.2.2.2]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Clone, PartialEq, Eq)] pub struct WireToSurface2Pdu { pub surface_id: u16, @@ -463,6 +466,7 @@ impl<'a> Decode<'a> for WireToSurface2Pdu { /// 2.2.2.3 RDPGFX_DELETE_ENCODING_CONTEXT_PDU /// /// [2.2.2.3]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteEncodingContextPdu { pub surface_id: u16, @@ -511,6 +515,7 @@ impl<'a> Decode<'a> for DeleteEncodingContextPdu { /// 2.2.2.4 RDPGFX_SOLID_FILL_PDU /// /// [2.2.2.4]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct SolidFillPdu { pub surface_id: u16, @@ -574,6 +579,7 @@ impl<'a> Decode<'a> for SolidFillPdu { /// 2.2.2.5 RDPGFX_SURFACE_TO_SURFACE_PDU /// /// [2.2.2.5]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct SurfaceToSurfacePdu { pub source_surface_id: u16, @@ -640,6 +646,7 @@ impl<'a> Decode<'a> for SurfaceToSurfacePdu { /// 2.2.2.6 RDPGFX_SURFACE_TO_CACHE_PDU /// /// [2.2.2.6]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct SurfaceToCachePdu { pub surface_id: u16, @@ -698,6 +705,7 @@ impl<'a> Decode<'a> for SurfaceToCachePdu { /// 2.2.2.7 RDPGFX_CACHE_TO_SURFACE_PDU /// /// [2.2.2.7]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CacheToSurfacePdu { pub cache_slot: u16, @@ -757,6 +765,7 @@ impl<'de> Decode<'de> for CacheToSurfacePdu { /// 2.2.2.8 RDPGFX_EVICT_CACHE_ENTRY_PDU /// /// [2.2.2.8]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct EvictCacheEntryPdu { pub cache_slot: u16, @@ -799,6 +808,7 @@ impl<'a> Decode<'a> for EvictCacheEntryPdu { /// 2.2.2.9 RDPGFX_CREATE_SURFACE_PDU /// /// [2.2.2.9]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateSurfacePdu { pub surface_id: u16, @@ -855,6 +865,7 @@ impl<'a> Decode<'a> for CreateSurfacePdu { /// 2.2.2.10 RDPGFX_DELETE_SURFACE_PDU /// /// [2.2.2.10]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteSurfacePdu { pub surface_id: u16, @@ -897,6 +908,7 @@ impl<'a> Decode<'a> for DeleteSurfacePdu { /// 2.2.2.11 RDPGFX_START_FRAME_PDU /// /// [2.2.2.11]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct StartFramePdu { pub timestamp: Timestamp, @@ -947,6 +959,23 @@ pub struct Timestamp { pub hours: u16, } +// Manual `Arbitrary` impl: the encoder packs the four fields into a single u32 via +// `set_bits` (milliseconds: 10 bits, seconds: 6 bits, minutes: 6 bits, hours: 10 bits). +// `derive(Arbitrary)` would generate the full `u8` / `u16` range, but `set_bits` panics +// when the value exceeds the requested bit width. Mask each field to its wire-allowed +// range so fuzz inputs always round-trip through `Encode`. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for Timestamp { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Self { + milliseconds: u.arbitrary::()? & 0x03FF, // 10 bits + seconds: u.arbitrary::()? & 0x3F, // 6 bits + minutes: u.arbitrary::()? & 0x3F, // 6 bits + hours: u.arbitrary::()? & 0x03FF, // 10 bits + }) + } +} + impl Timestamp { const NAME: &'static str = "GfxTimestamp"; @@ -1007,6 +1036,7 @@ impl<'a> Decode<'a> for Timestamp { /// 2.2.2.12 RDPGFX_END_FRAME_PDU /// /// [2.2.2.12]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct EndFramePdu { pub frame_id: u32, @@ -1049,6 +1079,7 @@ impl<'a> Decode<'a> for EndFramePdu { /// 2.2.2.13 RDPGFX_FRAME_ACKNOWLEDGE_PDU /// /// [2.2.2.13]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct FrameAcknowledgePdu { pub queue_depth: QueueDepth, @@ -1099,6 +1130,7 @@ impl<'a> Decode<'a> for FrameAcknowledgePdu { } #[repr(u32)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum QueueDepth { Unavailable, @@ -1127,6 +1159,7 @@ impl QueueDepth { /// 2.2.2.14 RDPGFX_RESET_GRAPHICS_PDU /// /// [2.2.2.14]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResetGraphicsPdu { pub width: u32, @@ -1212,6 +1245,7 @@ impl<'a> Decode<'a> for ResetGraphicsPdu { /// 2.2.2.15 RDPGFX_MAP_SURFACE_TO_OUTPUT_PDU /// /// [2.2.2.15]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct MapSurfaceToOutputPdu { pub surface_id: u16, @@ -1266,6 +1300,7 @@ impl<'a> Decode<'a> for MapSurfaceToOutputPdu { /// 2.2.2.16 RDPGFX_CACHE_IMPORT_OFFER_PDU /// /// [2.2.2.16]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CacheImportOfferPdu { pub cache_entries: Vec, @@ -1316,6 +1351,7 @@ impl<'a> Decode<'a> for CacheImportOfferPdu { /// 2.2.2.17 RDPGFX_CACHE_IMPORT_REPLY_PDU /// /// [2.2.2.17]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CacheImportReplyPdu { pub cache_slots: Vec, @@ -1367,6 +1403,7 @@ impl<'a> Decode<'a> for CacheImportReplyPdu { /// 2.2.2.16.1 RDPGFX_CACHE_ENTRY_METADATA /// /// [2.2.2.16.1]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CacheEntryMetadata { pub cache_key: u64, @@ -1412,6 +1449,7 @@ impl<'a> Decode<'a> for CacheEntryMetadata { /// 2.2.2.18 RDPGFX_CAPS_ADVERTISE_PDU /// /// [2.2.2.18]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapabilitiesAdvertisePdu(pub Vec); @@ -1467,6 +1505,7 @@ impl<'a> Decode<'a> for CapabilitiesAdvertisePdu { /// 2.2.2.19 RDPGFX_CAPS_CONFIRM_PDU /// /// [2.2.2.19]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapabilitiesConfirmPdu(pub RawCapabilitySet); @@ -1520,6 +1559,7 @@ impl<'a> Decode<'a> for CapabilitiesConfirmPdu { /// is known to this build. /// /// [2.2.1.6]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct RawCapabilitySet { pub version: CapabilityVersion, @@ -1660,6 +1700,7 @@ impl<'de> Decode<'de> for RawCapabilitySet { /// /// Holds only versions this build knows how to interpret. Obtained from /// [`RawCapabilitySet::parsed`], which returns `None` for unknown versions. +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum CapabilitySet { V8 { flags: CapabilitiesV8Flags }, @@ -1744,6 +1785,7 @@ impl From<&CapabilitySet> for RawCapabilitySet { } /// Capability set version, as advertised in 2.2.1.6 RDPGFX_CAPSET. +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct CapabilityVersion(pub u32); @@ -1792,6 +1834,7 @@ bitflags! { /// 2.2.3.1 RDPGFX_CAPSET_VERSION8 /// /// [2.2.3.1] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/027dd8eb-a066-42e8-ad65-2e0314c4dce5 + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilitiesV8Flags: u32 { const THIN_CLIENT = 0x1; @@ -1805,6 +1848,7 @@ bitflags! { /// 2.2.3.2 RDPGFX_CAPSET_VERSION81 /// /// [2.2.3.2] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/487e57cc-cd16-44c4-add8-60b84bf6d9e4 + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilitiesV81Flags: u32 { const THIN_CLIENT = 0x01; @@ -1819,6 +1863,7 @@ bitflags! { /// 2.2.3.3 RDPGFX_CAPSET_VERSION10 /// /// [2.2.3.3] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/d1899912-2b84-4e0d-9e6d-da0fd25d14bc + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilitiesV10Flags: u32 { const SMALL_CACHE = 0x02; @@ -1842,6 +1887,7 @@ bitflags! { /// 2.2.3.6 RDPGFX_CAPSET_VERSION103 /// /// [2.2.3.6] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/a73e87d5-10c3-4d3f-b00c-fd5579570a0b + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilitiesV103Flags: u32 { const AVC_DISABLED = 0x20; @@ -1855,6 +1901,7 @@ bitflags! { /// 2.2.3.7 RDPGFX_CAPSET_VERSION104 /// /// [2.2.3.7] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/be5ea8da-44db-478d-b55c-d42d82f11d26 + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilitiesV104Flags: u32 { const SMALL_CACHE = 0x02; @@ -1879,6 +1926,7 @@ bitflags! { /// 2.2.3.10 RDPGFX_CAPSET_VERSION107 /// /// [2.2.3.10] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/ba94595b-04de-4fbd-8ee4-89d8ff8f5cf1 + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilitiesV107Flags: u32 { const SMALL_CACHE = 0x02; @@ -1893,6 +1941,7 @@ bitflags! { /// 2.2.2.20 RDPGFX_MAP_SURFACE_TO_WINDOW_PDU /// /// [2.2.2.20]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct MapSurfaceToWindowPdu { pub surface_id: u16, @@ -1949,6 +1998,7 @@ impl<'a> Decode<'a> for MapSurfaceToWindowPdu { /// 2.2.2.21 RDPGFX_QOE_FRAME_ACKNOWLEDGE_PDU /// /// [2.2.2.21]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct QoeFrameAcknowledgePdu { pub frame_id: u32, @@ -2005,6 +2055,7 @@ impl<'a> Decode<'a> for QoeFrameAcknowledgePdu { /// 2.2.2.22 RDPGFX_MAP_SURFACE_TO_SCALED_OUTPUT_PDU /// /// [2.2.2.22]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct MapSurfaceToScaledOutputPdu { pub surface_id: u16, @@ -2067,6 +2118,7 @@ impl<'a> Decode<'a> for MapSurfaceToScaledOutputPdu { /// 2.2.2.23 RDPGFX_MAP_SURFACE_TO_SCALED_WINDOW_PDU /// /// [2.2.2.23] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct MapSurfaceToScaledWindowPdu { pub surface_id: u16, @@ -2129,6 +2181,7 @@ impl<'a> Decode<'a> for MapSurfaceToScaledWindowPdu { } #[repr(u16)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Codec1Type { Uncompressed = 0x0, @@ -2167,6 +2220,7 @@ impl From for u16 { } #[repr(u16)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Codec2Type { RemoteFxProgressive = 0x9, diff --git a/crates/ironrdp-egfx/src/pdu/common.rs b/crates/ironrdp-egfx/src/pdu/common.rs index 660f73d1f5..ef4c475554 100644 --- a/crates/ironrdp-egfx/src/pdu/common.rs +++ b/crates/ironrdp-egfx/src/pdu/common.rs @@ -6,6 +6,7 @@ use ironrdp_pdu::{ /// 2.2.1.1 RDPGFX_POINT16 /// /// [2.2.1.1]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct Point { pub x: u16, @@ -51,6 +52,7 @@ impl<'de> Decode<'de> for Point { /// 2.2.1.3 RDPGFX_COLOR32 /// /// [2.2.1.3]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct Color { pub b: u8, @@ -102,6 +104,7 @@ impl<'de> Decode<'de> for Color { /// 2.2.1.4 RDPGFX_PIXELFORMAT /// /// [2.2.1.4]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[repr(u8)] #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum PixelFormat { diff --git a/xtask/src/features.rs b/xtask/src/features.rs index 2561eab35c..b90916392e 100644 --- a/xtask/src/features.rs +++ b/xtask/src/features.rs @@ -90,6 +90,14 @@ const CASES: &[FeatureCheckCase] = &[ features: &["arbitrary", "alloc"], }, }, + FeatureCheckCase { + name: "ironrdp-egfx/arbitrary", + invocation: Invocation::CargoCheck { + package: "ironrdp-egfx", + no_default_features: false, + features: &["arbitrary"], + }, + }, // Workspace powerset, partitioned by layer so each fan-out worker stays bounded. // Adding a new crate to a group means the powerset picks it up on the next run. FeatureCheckCase { From 1534d1b40e902a404b020fbae8e970a65ca74458 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 1 Jun 2026 11:40:11 -0500 Subject: [PATCH 270/325] fix(egfx)!: make DecodedFrame fields private with getters to enforce size invariant (#1331) --- crates/ironrdp-egfx/src/client.rs | 8 +-- crates/ironrdp-egfx/src/decode.rs | 56 +++++++++++++++---- .../tests/egfx/decode.rs | 14 ++--- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/crates/ironrdp-egfx/src/client.rs b/crates/ironrdp-egfx/src/client.rs index 91dd87dfb0..4610f7ca0f 100644 --- a/crates/ironrdp-egfx/src/client.rs +++ b/crates/ironrdp-egfx/src/client.rs @@ -751,10 +751,10 @@ impl GraphicsPipelineClient { // Decoded frame must be at least as large as the destination rectangle. // Larger is expected (macroblock alignment) and handled by cropping. // Smaller means the server sent mismatched dimensions. - if frame.width < u32::from(dest_width) || frame.height < u32::from(dest_height) { + if frame.width() < u32::from(dest_width) || frame.height() < u32::from(dest_height) { warn!( - frame_width = frame.width, - frame_height = frame.height, + frame_width = frame.width(), + frame_height = frame.height(), dest_width, dest_height, "decoded frame smaller than destination rectangle" @@ -762,7 +762,7 @@ impl GraphicsPipelineClient { return Err(pdu_other_err!("decoded frame smaller than destination rectangle")); } - let cropped_data = crop_decoded_frame(&frame.data, frame.width, frame.height, dest_width, dest_height); + let cropped_data = crop_decoded_frame(frame.data(), frame.width(), frame.height(), dest_width, dest_height); let update = BitmapUpdate { surface_id, diff --git a/crates/ironrdp-egfx/src/decode.rs b/crates/ironrdp-egfx/src/decode.rs index c94bcecd60..403924120f 100644 --- a/crates/ironrdp-egfx/src/decode.rs +++ b/crates/ironrdp-egfx/src/decode.rs @@ -29,12 +29,9 @@ use core::fmt; #[derive(Clone)] #[non_exhaustive] pub struct DecodedFrame { - /// RGBA pixel data (4 bytes per pixel) - pub data: Vec, - /// Frame width in pixels - pub width: u32, - /// Frame height in pixels - pub height: u32, + data: Vec, + width: u32, + height: u32, } impl DecodedFrame { @@ -50,6 +47,26 @@ impl DecodedFrame { ); Self { data, width, height } } + + /// RGBA pixel data (4 bytes per pixel). + pub fn data(&self) -> &[u8] { + &self.data + } + + /// Frame width in pixels. + pub fn width(&self) -> u32 { + self.width + } + + /// Frame height in pixels. + pub fn height(&self) -> u32 { + self.height + } + + /// Consume the frame and return the owned RGBA buffer. + pub fn into_data(self) -> Vec { + self.data + } } impl fmt::Debug for DecodedFrame { @@ -286,11 +303,7 @@ mod openh264_impl { let mut rgba = vec![0u8; rgba_size]; yuv.write_rgba8(&mut rgba); - Ok(DecodedFrame { - data: rgba, - width: w32, - height: h32, - }) + Ok(DecodedFrame::new(rgba, w32, h32)) } fn reset(&mut self) { @@ -309,3 +322,24 @@ mod openh264_impl { #[cfg(feature = "openh264")] pub use openh264_impl::OpenH264Decoder; + +#[cfg(test)] +mod tests { + use super::DecodedFrame; + + #[test] + fn getters_return_constructor_inputs() { + let data = vec![0u8; 2 * 3 * 4]; + let frame = DecodedFrame::new(data.clone(), 2, 3); + assert_eq!(frame.data(), data.as_slice()); + assert_eq!(frame.width(), 2); + assert_eq!(frame.height(), 3); + } + + #[test] + fn into_data_yields_owned_buffer() { + let data = vec![0xAAu8; 4 * 4 * 4]; + let frame = DecodedFrame::new(data.clone(), 4, 4); + assert_eq!(frame.into_data(), data); + } +} diff --git a/crates/ironrdp-testsuite-core/tests/egfx/decode.rs b/crates/ironrdp-testsuite-core/tests/egfx/decode.rs index e0276f8ce8..fb4d8aac67 100644 --- a/crates/ironrdp-testsuite-core/tests/egfx/decode.rs +++ b/crates/ironrdp-testsuite-core/tests/egfx/decode.rs @@ -86,8 +86,8 @@ fn test_openh264_decode_sps_pps() { let mut decoder = OpenH264Decoder::new().expect("decoder should initialize"); let frame = decoder.decode(&avc_data).expect("decode should succeed"); - assert!(frame.width >= 16, "decoded width should be at least 16"); - assert!(frame.height >= 16, "decoded height should be at least 16"); + assert!(frame.width() >= 16, "decoded width should be at least 16"); + assert!(frame.height() >= 16, "decoded height should be at least 16"); } #[test] @@ -98,9 +98,9 @@ fn test_openh264_decode_iframe() { let frame = decoder.decode(&avc_data).expect("decode should succeed"); // Verify RGBA output dimensions and data - assert_eq!(frame.width, 16); - assert_eq!(frame.height, 16); - assert_eq!(frame.data.len(), 16 * 16 * 4, "RGBA data should be 16x16x4 bytes"); + assert_eq!(frame.width(), 16); + assert_eq!(frame.height(), 16); + assert_eq!(frame.data().len(), 16 * 16 * 4, "RGBA data should be 16x16x4 bytes"); } #[test] @@ -116,8 +116,8 @@ fn test_openh264_decoder_reset() { // Decoder should still be usable after reset let frame = decoder.decode(&avc_data).expect("decode after reset should succeed"); - assert_eq!(frame.width, 16); - assert_eq!(frame.height, 16); + assert_eq!(frame.width(), 16); + assert_eq!(frame.height(), 16); } // ============================================================================ From ef20ea4e90455d6c6db0d3521f6522d1e960c0bb Mon Sep 17 00:00:00 2001 From: clintcan Date: Tue, 2 Jun 2026 03:11:00 +0800 Subject: [PATCH 271/325] fix(session): decode RGBA QOI bitmaps instead of dropping the frame (#1341) Fixes the client-side QOI decode path in ironrdp-session so RGBA-channel QOI frames are decoded and applied to the framebuffer instead of being dropped, improving interoperability with third-party RDP servers and older ironrdp-server builds that emit RGBA QOI. --- crates/ironrdp-session/src/fast_path.rs | 44 +++++++++++++----- crates/ironrdp-session/src/image.rs | 62 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/crates/ironrdp-session/src/fast_path.rs b/crates/ironrdp-session/src/fast_path.rs index 9524030316..2a05d1a230 100644 --- a/crates/ironrdp-session/src/fast_path.rs +++ b/crates/ironrdp-session/src/fast_path.rs @@ -552,19 +552,39 @@ fn qoi_apply( update_rectangle: &mut Option, ) -> SessionResult<()> { let (header, decoded) = qoi::decode_to_vec(data).map_err(|e| reason_err!("QOI decode", "{}", e))?; - match header.channels { - qoi::Channels::Rgb => { - let rectangle = image.apply_rgb24(&decoded, &destination, false)?; - - *update_rectangle = update_rectangle - .as_ref() - .map(|rect: &InclusiveRectangle| rect.union(&rectangle)) - .or(Some(rectangle)); - } - qoi::Channels::Rgba => { - warn!("Unsupported RGBA QOI data"); - } + + // Guard against a decoded buffer that doesn't match the destination + // rectangle. `apply_rgb24`/`apply_rgba32` derive the row count from the + // decoded length, and the only bounds check downstream (`rect_fits`) + // validates the rectangle against the image, not the buffer against the + // rectangle. A malformed/oversized QOI payload would otherwise drive the + // per-row index past `self.data` and panic (client-side DoS). + let channels = match header.channels { + qoi::Channels::Rgb => 3, + qoi::Channels::Rgba => 4, + }; + let expected = usize::from(destination.width()) * usize::from(destination.height()) * channels; + if decoded.len() != expected { + return Err(reason_err!( + "QOI decode", + "decoded {} bytes, expected {} for {}x{} ({} channels)", + decoded.len(), + expected, + destination.width(), + destination.height(), + channels + )); } + + let rectangle = match header.channels { + qoi::Channels::Rgb => image.apply_rgb24(&decoded, &destination, false)?, + qoi::Channels::Rgba => image.apply_rgba32(&decoded, &destination, false)?, + }; + + *update_rectangle = update_rectangle + .as_ref() + .map(|rect: &InclusiveRectangle| rect.union(&rectangle)) + .or(Some(rectangle)); Ok(()) } diff --git a/crates/ironrdp-session/src/image.rs b/crates/ironrdp-session/src/image.rs index a578b1fcb6..0d420726b8 100644 --- a/crates/ironrdp-session/src/image.rs +++ b/crates/ironrdp-session/src/image.rs @@ -809,6 +809,68 @@ impl DecodedImage { } } + #[cfg(feature = "qoi")] + fn apply_rgba32_iter<'a, I>( + &mut self, + rgba32: I, + update_rectangle: &InclusiveRectangle, + ) -> SessionResult + where + I: Iterator, + { + if !self.rect_fits(update_rectangle) { + debug!( + "Skipping rgba32 update {:?} outside image bounds {}x{}", + update_rectangle, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + + const SRC_COLOR_DEPTH: usize = 4; + const DST_COLOR_DEPTH: usize = 4; + + let image_width = usize::from(self.width); + let top = usize::from(update_rectangle.top); + let left = usize::from(update_rectangle.left); + let [ri, gi, bi, ai] = self.pixel_format.channel_offsets(); + + let pointer_rendering_state = self.pointer_rendering_begin(update_rectangle)?; + + rgba32.enumerate().for_each(|(row_idx, row)| { + row.chunks_exact(SRC_COLOR_DEPTH) + .enumerate() + .for_each(|(col_idx, src_pixel)| { + let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; + + self.data[dst_idx + ri] = src_pixel[0]; + self.data[dst_idx + gi] = src_pixel[1]; + self.data[dst_idx + bi] = src_pixel[2]; + self.data[dst_idx + ai] = src_pixel[3]; + }) + }); + + let update_rectangle = self.pointer_rendering_end(pointer_rendering_state)?; + + Ok(update_rectangle) + } + + #[cfg(feature = "qoi")] + pub(crate) fn apply_rgba32( + &mut self, + rgba32: &[u8], + update_rectangle: &InclusiveRectangle, + flip: bool, + ) -> SessionResult { + const SRC_COLOR_DEPTH: usize = 4; + let rectangle_width = usize::from(update_rectangle.width()); + let lines = rgba32.chunks_exact(rectangle_width * SRC_COLOR_DEPTH); + if flip { + self.apply_rgba32_iter(lines.rev(), update_rectangle) + } else { + self.apply_rgba32_iter(lines, update_rectangle) + } + } + pub(crate) fn apply_rgb32_bitmap( &mut self, rgb32: &[u8], From f21470c6dc20e1b10b4bbf750a406644479a4b35 Mon Sep 17 00:00:00 2001 From: uchouT Date: Tue, 2 Jun 2026 19:41:48 +0800 Subject: [PATCH 272/325] fix(dvc)!: add channel_id parameter to DvcChannelListener::create (#1358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the dynamic virtual channel (DVC) client listener interface in ironrdp-dvc to pass the channel_id (from the incoming DYNVC_CREATE_REQ) into the listener’s create method, enabling listeners to differentiate/control per-instance behavior based on the negotiated dynamic channel ID. --- crates/ironrdp-dvc/src/client.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ironrdp-dvc/src/client.rs b/crates/ironrdp-dvc/src/client.rs index 4a2001c4a4..c383307204 100644 --- a/crates/ironrdp-dvc/src/client.rs +++ b/crates/ironrdp-dvc/src/client.rs @@ -25,7 +25,7 @@ pub trait DvcChannelListener: Send { /// Called for each incoming DYNVC_CREATE_REQ matching this name. /// Return `None` to reject (NO_LISTENER). - fn create(&mut self) -> Option>; + fn create(&mut self, channel_id: DynamicChannelId) -> Option>; } pub type DynamicChannelListener = Box; @@ -51,7 +51,7 @@ impl DvcChannelListener for OnceListener { .channel_name() } - fn create(&mut self) -> Option> { + fn create(&mut self, _channel_id: DynamicChannelId) -> Option> { self.inner.take() } } @@ -320,7 +320,7 @@ impl DynamicChannelSet { channel_id: DynamicChannelId, ) -> Option<&mut DynamicVirtualChannel> { let entry = self.listeners.get_mut(name)?; - let processor = entry.listener.create()?; + let processor = entry.listener.create(channel_id)?; if let Some(type_id) = entry.type_id { self.type_id_to_channel_id.insert(type_id, channel_id); From 8a3b12639632f58291442a292a89fc6e22f82985 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Thu, 4 Jun 2026 21:24:08 -0500 Subject: [PATCH 273/325] feat(server): add CredentialValidator trait for server-side auth (#1172) --- Cargo.lock | 2 + crates/ironrdp-server/src/builder.rs | 26 ++- crates/ironrdp-server/src/lib.rs | 3 +- crates/ironrdp-server/src/server.rs | 198 ++++++++++++++++++ crates/ironrdp-testsuite-core/Cargo.toml | 2 + .../tests/server/credential_validator.rs | 74 +++++++ .../tests/server/mod.rs | 1 + 7 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 crates/ironrdp-testsuite-core/tests/server/credential_validator.rs diff --git a/Cargo.lock b/Cargo.lock index 608bf37f97..0172548255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2914,6 +2914,7 @@ version = "0.0.0" dependencies = [ "anyhow", "array-concat", + "async-trait", "expect-test", "hex", "ironrdp-acceptor", @@ -2945,6 +2946,7 @@ dependencies = [ "pretty_assertions", "proptest", "rstest", + "tokio", "visibility", ] diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index cc5d8e8b4f..fb959830ce 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -11,7 +11,7 @@ use super::display::{DesktopSize, RdpServerDisplay}; #[cfg(feature = "egfx")] use super::gfx::GfxServerFactory; use super::handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; -use super::server::{ConnectionHandler, RdpServer, RdpServerOptions, RdpServerSecurity}; +use super::server::{ConnectionHandler, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity}; use crate::{DisplayUpdate, RdpServerDisplayUpdates, SoundServerFactory}; pub struct WantsAddr {} @@ -37,6 +37,7 @@ pub struct BuilderDone { cliprdr_factory: Option>, sound_factory: Option>, connection_handler: Option>, + credential_validator: Option>, #[cfg(feature = "egfx")] gfx_factory: Option>, display_suppressed: Option>, @@ -133,6 +134,7 @@ impl RdpServerBuilder { sound_factory: None, cliprdr_factory: None, connection_handler: None, + credential_validator: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] @@ -152,6 +154,7 @@ impl RdpServerBuilder { sound_factory: None, cliprdr_factory: None, connection_handler: None, + credential_validator: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] @@ -223,8 +226,23 @@ impl RdpServerBuilder { self } + /// Set a credential validator for TLS-mode connections. + /// + /// When set, credentials received from the client during + /// `SecureSettingsExchange` (`ClientInfoPdu`) are passed to this + /// validator before the session is established. Rejection or a backend + /// error closes the connection. Pass `None` (the default) to skip + /// validation entirely. + /// + /// Not used for CredSSP/Hybrid connections (those use pre-loaded + /// credentials for NTLM challenge-response). + pub fn with_credential_validator(mut self, validator: Option>) -> Self { + self.state.credential_validator = validator; + self + } + pub fn build(self) -> RdpServer { - RdpServer::new( + let mut server = RdpServer::new( RdpServerOptions { addr: self.state.addr, security: self.state.security, @@ -239,7 +257,9 @@ impl RdpServerBuilder { #[cfg(feature = "egfx")] self.state.gfx_factory, self.state.display_suppressed, - ) + ); + server.set_credential_validator(self.state.credential_validator); + server } } diff --git a/crates/ironrdp-server/src/lib.rs b/crates/ironrdp-server/src/lib.rs index 0a4da486ad..4ae6b71679 100644 --- a/crates/ironrdp-server/src/lib.rs +++ b/crates/ironrdp-server/src/lib.rs @@ -33,7 +33,8 @@ pub use handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; #[cfg(feature = "helper")] pub use helper::TlsIdentityCtx; pub use server::{ - ConnectionHandler, Credentials, PostConnectionAction, RdpServer, RdpServerOptions, RdpServerSecurity, ServerEvent, + ConnectionHandler, CredentialDecision, CredentialValidationError, CredentialValidator, Credentials, + ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, RdpServerSecurity, ServerEvent, ServerEventSender, }; pub use sound::{RdpsndServerHandler, RdpsndServerMessage, SoundServerFactory}; diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 810bf6276c..0be2a0707b 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1,3 +1,4 @@ +use core::fmt; use core::net::SocketAddr; use core::sync::atomic::{AtomicBool, Ordering}; use core::time::Duration; @@ -19,6 +20,7 @@ use ironrdp_pdu::mcs::{SendDataIndication, SendDataRequest}; use ironrdp_pdu::rdp::capability_sets::{BitmapCodecs, CapabilitySet, CmdFlags, CodecProperty, GeneralExtraFlags}; pub use ironrdp_pdu::rdp::client_info::Credentials; use ironrdp_pdu::rdp::headers::{ServerDeactivateAll, ShareControlPdu}; +use ironrdp_pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, ServerSetErrorInfoPdu}; use ironrdp_pdu::x224::X224; use ironrdp_pdu::{Action, PduResult, decode_err, mcs, nego, rdp}; use ironrdp_rdpsnd as rdpsnd; @@ -89,6 +91,132 @@ pub trait ConnectionHandler: Send { } } +/// Outcome of a successful [`CredentialValidator::validate`] call. +/// +/// A rejection from a working validator is not an error: the validator did +/// its job and decided the credentials do not authenticate. Backend failures +/// (LDAP unreachable, PAM transport broken, database connection lost) are +/// reported via [`CredentialValidationError`] instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialDecision { + /// Credentials accepted; the connection proceeds. + Accept, + /// Credentials rejected; the connection is closed. + Reject, +} + +/// Error returned by a [`CredentialValidator`] when the validator backend +/// itself fails (rather than the credentials being invalid). +/// +/// Wraps any [`core::error::Error`] from the backend (LDAP/PAM/DB/etc.) so +/// the trait does not require a particular error library in implementors or +/// consumers. +#[derive(Debug)] +pub struct CredentialValidationError { + source: Box, +} + +impl CredentialValidationError { + /// Wrap a backend error as a credential-validation failure. + pub fn new(source: E) -> Self + where + E: core::error::Error + Send + Sync + 'static, + { + Self { + source: Box::new(source), + } + } +} + +impl fmt::Display for CredentialValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("credential validator backend failure") + } +} + +impl core::error::Error for CredentialValidationError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + Some(&*self.source) + } +} + +/// Server-side credential validator for TLS-mode connections. +/// +/// Called during connection setup when the server receives client credentials +/// via `ClientInfoPdu`. Not used for CredSSP/Hybrid connections (those use +/// pre-loaded credentials for NTLM challenge-response). +/// +/// Implement this trait to validate credentials against external systems +/// (PAM, LDAP, database, etc.). For blocking backends, wrap the call in +/// `tokio::task::spawn_blocking` to avoid stalling the async runtime. +/// +/// # Example +/// +/// ```ignore +/// use ironrdp_server::{CredentialDecision, CredentialValidationError, CredentialValidator, Credentials}; +/// +/// struct StaticValidator { +/// expected_user: String, +/// expected_password: String, +/// } +/// +/// #[async_trait::async_trait] +/// impl CredentialValidator for StaticValidator { +/// async fn validate( +/// &self, +/// creds: &Credentials, +/// ) -> Result { +/// if creds.username == self.expected_user && creds.password == self.expected_password { +/// Ok(CredentialDecision::Accept) +/// } else { +/// Ok(CredentialDecision::Reject) +/// } +/// } +/// } +/// ``` +#[async_trait::async_trait] +pub trait CredentialValidator: Send + Sync { + /// Validate credentials received from the client. + /// + /// Return `Ok(CredentialDecision::Accept)` to permit the connection, + /// `Ok(CredentialDecision::Reject)` to refuse it. Return + /// `Err(CredentialValidationError::new(_))` only when the validator + /// itself could not produce a decision (backend system error). + /// + /// Implementors backed by blocking systems (PAM, libldap, a synchronous + /// database driver) should offload the work, for example with + /// `tokio::task::spawn_blocking`, so the returned future does not stall the + /// caller's executor. Native-async backends can simply `.await`. + async fn validate(&self, credentials: &Credentials) -> Result; +} + +/// A built-in [`CredentialValidator`] that accepts exactly one fixed set of credentials. +/// +/// This is the validation-policy equivalent of the acceptor's pre-loaded +/// exact-match: it keeps the common "one known account" case a one-liner while +/// going through the same hook as PAM, LDAP, or database-backed validators. +pub struct ExactMatchCredentialValidator { + expected: Credentials, +} + +impl ExactMatchCredentialValidator { + /// Build a validator that accepts only `expected` and rejects everything else. + pub fn new(expected: Credentials) -> Self { + Self { expected } + } +} + +#[async_trait::async_trait] +impl CredentialValidator for ExactMatchCredentialValidator { + async fn validate(&self, credentials: &Credentials) -> Result { + if credentials == &self.expected { + Ok(CredentialDecision::Accept) + } else { + Ok(CredentialDecision::Reject) + } + } +} + #[derive(Clone)] pub struct RdpServerOptions { pub addr: SocketAddr, @@ -298,6 +426,7 @@ pub struct RdpServer { ev_sender: mpsc::UnboundedSender, ev_receiver: Arc>>, creds: Option, + credential_validator: Option>, local_addr: Option, autodetect: Option, connection_handler: Option>, @@ -392,6 +521,7 @@ impl RdpServer { ev_sender, ev_receiver: Arc::new(Mutex::new(ev_receiver)), creds: None, + credential_validator: None, local_addr: None, autodetect: None, connection_handler, @@ -403,6 +533,25 @@ impl RdpServer { builder::RdpServerBuilder::new() } + /// Set or clear the credential validator for TLS-mode connections. + /// + /// When set, credentials received from the client during + /// `SecureSettingsExchange` are validated through this callback before + /// the session is established. If the validator returns + /// [`CredentialDecision::Reject`] (or a [`CredentialValidationError`]), + /// the connection is rejected. Passing `None` clears any previously + /// configured validator. + /// + /// Most callers should configure the validator at construction time via + /// the builder's `with_credential_validator` method + /// ([`RdpServer::builder`]); this setter exists for dynamic + /// post-construction reconfiguration. + /// + /// Not used for CredSSP/Hybrid connections (those use pre-loaded credentials). + pub fn set_credential_validator(&mut self, validator: Option>) { + self.credential_validator = validator; + } + pub fn event_sender(&self) -> &mpsc::UnboundedSender { &self.ev_sender } @@ -1034,6 +1183,32 @@ impl RdpServer { { debug!("Client accepted"); + // Validate credentials if a validator is configured. The validator runs here, in the + // async server layer, rather than in the sans-I/O acceptor, because real validators + // (PAM/LDAP/DB) are I/O-bound. On rejection, deny with a ServerSetErrorInfoPdu before + // closing, matching the acceptor's exact-match denial path. + if let Some(validator) = self.credential_validator.clone() { + if let Some(creds) = &result.credentials { + match validator.validate(creds).await { + Ok(CredentialDecision::Accept) => { + debug!("Credential validation accepted"); + } + Ok(CredentialDecision::Reject) => { + warn!("Credential validation rejected"); + send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; + bail!("credential validation rejected"); + } + Err(e) => { + error!(error = %e, "Credential validator backend error"); + send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; + bail!("credential validation backend error"); + } + } + } else { + debug!("Skipping credential validation (no credentials in AcceptorResult)"); + } + } + if !result.input_events.is_empty() { debug!("Handling input event backlog from acceptor sequence"); self.handle_input_backlog( @@ -1453,6 +1628,29 @@ async fn deactivate_all( Ok(()) } +/// Send a `ServerSetErrorInfoPdu(ServerDeniedConnection)` to the client, then return. +/// +/// Used to deny a connection after credential validation rejects it, mirroring the +/// acceptor's exact-match denial so both paths refuse the same spec-defined way. +async fn send_access_denied( + io_channel_id: u16, + user_channel_id: u16, + writer: &mut impl FramedWrite, +) -> Result<(), anyhow::Error> { + let info = ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode( + ProtocolIndependentCode::ServerDeniedConnection, + )); + let user_data = encode_vec(&info)?.into(); + let pdu = SendDataIndication { + initiator_id: user_channel_id, + channel_id: io_channel_id, + user_data, + }; + let msg = encode_vec(&X224(pdu))?; + writer.write_all(&msg).await?; + Ok(()) +} + struct SharedWriter<'w, W: FramedWrite> { writer: Rc>, } diff --git a/crates/ironrdp-testsuite-core/Cargo.toml b/crates/ironrdp-testsuite-core/Cargo.toml index 9c6961c96b..68cbd45fd8 100644 --- a/crates/ironrdp-testsuite-core/Cargo.toml +++ b/crates/ironrdp-testsuite-core/Cargo.toml @@ -34,6 +34,7 @@ visibility = { version = "0.1", optional = true } [dev-dependencies] anyhow = "1" +async-trait = "0.1" expect-test.workspace = true hex = "0.4" ironrdp-cliprdr-format.path = "../ironrdp-cliprdr-format" @@ -60,6 +61,7 @@ png = "0.18" pretty_assertions = "1.4" proptest.workspace = true rstest.workspace = true +tokio = { version = "1", features = ["macros", "rt"] } [lints] workspace = true diff --git a/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs b/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs new file mode 100644 index 0000000000..4b4dd280a3 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs @@ -0,0 +1,74 @@ +use core::fmt; +use std::sync::Arc; + +use async_trait::async_trait; +use ironrdp_server::{CredentialDecision, CredentialValidationError, CredentialValidator, Credentials}; + +fn fixed_creds() -> Credentials { + Credentials { + username: "alice".to_owned(), + password: "hunter2".to_owned(), + domain: None, + } +} + +struct AlwaysAccept; +#[async_trait] +impl CredentialValidator for AlwaysAccept { + async fn validate(&self, _: &Credentials) -> Result { + Ok(CredentialDecision::Accept) + } +} + +struct AlwaysReject; +#[async_trait] +impl CredentialValidator for AlwaysReject { + async fn validate(&self, _: &Credentials) -> Result { + Ok(CredentialDecision::Reject) + } +} + +#[derive(Debug)] +struct BackendDown; +impl fmt::Display for BackendDown { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ldap server unreachable") + } +} +impl core::error::Error for BackendDown {} + +struct AlwaysBackendError; +#[async_trait] +impl CredentialValidator for AlwaysBackendError { + async fn validate(&self, _: &Credentials) -> Result { + Err(CredentialValidationError::new(BackendDown)) + } +} + +#[tokio::test] +async fn validator_accept_returns_accept() { + let v = AlwaysAccept; + assert_eq!(v.validate(&fixed_creds()).await.unwrap(), CredentialDecision::Accept); +} + +#[tokio::test] +async fn validator_reject_returns_reject() { + let v = AlwaysReject; + assert_eq!(v.validate(&fixed_creds()).await.unwrap(), CredentialDecision::Reject); +} + +#[tokio::test] +async fn validator_backend_error_propagates_source() { + let v = AlwaysBackendError; + let err = v.validate(&fixed_creds()).await.expect_err("expected backend error"); + assert_eq!(err.to_string(), "credential validator backend failure"); + let inner = core::error::Error::source(&err).expect("source must be Some"); + assert_eq!(inner.to_string(), "ldap server unreachable"); +} + +#[tokio::test] +async fn validator_can_be_held_behind_arc_dyn() { + // Exercises the Send + Sync + 'static bounds the trait promises through Arc. + let v: Arc = Arc::new(AlwaysAccept); + assert_eq!(v.validate(&fixed_creds()).await.unwrap(), CredentialDecision::Accept); +} diff --git a/crates/ironrdp-testsuite-core/tests/server/mod.rs b/crates/ironrdp-testsuite-core/tests/server/mod.rs index 6717853733..7706b1c82b 100644 --- a/crates/ironrdp-testsuite-core/tests/server/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/server/mod.rs @@ -1,3 +1,4 @@ mod acceptor; mod autodetect; +mod credential_validator; mod fast_path; From 90461444f994e68847c38f7f1c27d21fe95f2839 Mon Sep 17 00:00:00 2001 From: uchouT Date: Mon, 8 Jun 2026 17:46:37 +0800 Subject: [PATCH 274/325] refactor(rdpeusb): split PDU and TS_URB enum wrapper (#1321) Signed-off-by: uchouT --- crates/ironrdp-rdpeusb/src/pdu/caps.rs | 6 +- .../ironrdp-rdpeusb/src/pdu/completion/mod.rs | 97 ++- .../src/pdu/completion/ts_urb_result.rs | 10 +- crates/ironrdp-rdpeusb/src/pdu/header.rs | 91 ++- .../src/pdu/iface_manipulation.rs | 179 +++++ crates/ironrdp-rdpeusb/src/pdu/mod.rs | 406 +++++++---- crates/ironrdp-rdpeusb/src/pdu/notify.rs | 10 +- crates/ironrdp-rdpeusb/src/pdu/sink.rs | 33 +- crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs | 149 ++-- .../src/pdu/usb_dev/ts_urb/mod.rs | 667 +++++++++--------- .../src/pdu/usb_dev/ts_urb/utils.rs | 262 +++---- 11 files changed, 1076 insertions(+), 834 deletions(-) create mode 100644 crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs diff --git a/crates/ironrdp-rdpeusb/src/pdu/caps.rs b/crates/ironrdp-rdpeusb/src/pdu/caps.rs index a78fb0a5e6..b61f5d2c61 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/caps.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/caps.rs @@ -44,8 +44,7 @@ impl RimExchangeCapabilityRequest { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: InterfaceId::CAPABILITIES, - mask: Mask::StreamIdNone, + iface_id: InterfaceId::CAPABILITIES.with_mask(Mask::None), msg_id: self.msg_id, function_id: Some(FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST), } @@ -107,8 +106,7 @@ impl RimExchangeCapabilityResponse { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: InterfaceId::CAPABILITIES, - mask: Mask::StreamIdNone, + iface_id: InterfaceId::CAPABILITIES.with_mask(Mask::None), msg_id: self.msg_id, function_id: None, } diff --git a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs index 8e5f26a474..c9139b1b92 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs @@ -71,14 +71,13 @@ pub struct IoControlCompletion { impl IoControlCompletion { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.completion_iface, - mask: Mask::StreamIdProxy, + iface_id: self.completion_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::IOCONTROL_COMPLETION), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { const FIXED: usize = 4 /* RequestId */ + 4 /* HResult */ + 4 /* Information */ + 4 /* OutputBufferSize */; ensure_size!(in: src, size: FIXED); @@ -87,40 +86,42 @@ impl IoControlCompletion { let information = src.read_u32(); let output_buffer_size = src.read_u32(); - // Should this stuff be part of some validate() function? - if hresult == 0 { - if information != output_buffer_size { - return Err(invalid_field_err!( - "Information != OutputBufferSize", - "HResult is: 0x0 (IOCTL success), but Information != OutputBufferSize" - )); - } - } else if hresult != HRESULT_FROM_WIN32_ERROR_INSUFFICIENT_BUFFER && output_buffer_size != 0 { - // > If the HResult field is equal to HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER) - // > then ... . For any other case `OutputBufferSize` **MUST** be set to 0 ... - // - // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 - return Err(invalid_field_err!( - "OutputBufferSize", - "HResult is not one of: 0x0 (success), 0x8007007A (insufficient buffer error), \ - so expected OutputBufferSize: 0x0" - )); - } + let n = output_buffer_size.try_into().map_err(|e| other_err!(source: e))?; let output_buffer = match hresult { - // #[expect(clippy::as_conversions)] - 0 | HRESULT_FROM_WIN32_ERROR_INSUFFICIENT_BUFFER => { - let n = information.try_into().map_err(|e| other_err!(source: e))?; - Vec::from(src.read_slice(n)) + 0 => { + if information != output_buffer_size { + return Err(invalid_field_err!( + "Information != OutputBufferSize", + "HResult is: 0x0 (IOCTL success), but Information != OutputBufferSize" + )); + } + ensure_size!(in: src, size: n); + src.read_slice(n).to_vec() + } + HRESULT_FROM_WIN32_ERROR_INSUFFICIENT_BUFFER => { + ensure_size!(in: src, size: n); + src.read_slice(n).to_vec() + } + _ => { + if output_buffer_size != 0 { + // > If the HResult field is equal to HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER) + // > then ... . For any other case `OutputBufferSize` **MUST** be set to 0 ... + // + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 + return Err(invalid_field_err!( + "OutputBufferSize", + "HResult is not one of: 0x0 (success), 0x8007007A (insufficient buffer error), \ + so expected OutputBufferSize: 0x0" + )); + } + Vec::new() } - // > For any other case [OutputBufferSize] MUST be set to 0 - // Which means empty output_buffer - _ => Vec::new(), }; Ok(Self { - msg_id: header.msg_id, - completion_iface: header.interface_id, + msg_id, + completion_iface: udev_iface, request_id, hresult, information, @@ -151,24 +152,14 @@ impl Encode for IoControlCompletion { } fn size(&self) -> usize { - #[expect(clippy::as_conversions)] - let out_buf = if self.hresult == 0 { - assert_eq!(self.information, self.output_buffer_size); - self.output_buffer.len() - } else if self.hresult == HRESULT_FROM_WIN32_ERROR_INSUFFICIENT_BUFFER { - self.information as usize - } else { - 0 - }; - strict_sum(&[SharedMsgHeader::SIZE_REQ + const { size_of::(/* RequestId */) + size_of::() - + size_of::(/* Information */) - + size_of::(/* OutputBufferSize */) + + 4 /* Information */ + + 4 /* OutputBufferSize */ } - + out_buf]) + + self.output_buffer.len()]) } } @@ -194,14 +185,13 @@ pub struct UrbCompletion { impl UrbCompletion { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.completion_iface, - mask: Mask::StreamIdProxy, + iface_id: self.completion_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::URB_COMPLETION), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: 4 /* RequestId */ + 4 /* CbTsUrbResult */); let req_id = RequestIdTransferInOut::try_from(src.read_u32()) .map_err(|reason| invalid_field_err!("URB_COMPLETION::RequestId", reason))?; @@ -225,8 +215,8 @@ impl UrbCompletion { ensure_size!(in: src, size: output_buffer_size); let output_buffer = src.read_slice(output_buffer_size).to_vec(); Ok(Self { - msg_id: header.msg_id, - completion_iface: header.interface_id, + msg_id, + completion_iface: udev_iface, req_id, ts_urb_result, hresult, @@ -297,14 +287,13 @@ pub struct UrbCompletionNoData { impl UrbCompletionNoData { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.completion_iface, - mask: Mask::StreamIdProxy, + iface_id: self.completion_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::URB_COMPLETION_NO_DATA), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: 4 /* RequestId */ + 4 /* CbTsUrbResult */); let req_id = RequestIdTransferInOut::try_from(src.read_u32()) .map_err(|reason| invalid_field_err!("URB_COMPLETION_NO_DATA::RequestId", reason))?; @@ -316,8 +305,8 @@ impl UrbCompletionNoData { let hresult = src.read_u32(); let output_buffer_size = src.read_u32(); Ok(Self { - msg_id: header.msg_id, - completion_iface: header.interface_id, + msg_id, + completion_iface: udev_iface, req_id, ts_urb_result, hresult, diff --git a/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs b/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs index 2cc22260ba..0263f8f158 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs @@ -1,4 +1,4 @@ -//! Packets sent as responses to [`TsUrb`]s received from the server as part of +//! Packets sent as responses to [`TsUrbIn`] and [`TsUrbOut`] received from the server as part of //! [`TransferInRequest`] and [`TransferOutRequest`] messages. //! //! The [`TsUrbResult`] packets are sent as part of [`UrbCompletion`] or [`UrbCompletionNoData`]. @@ -43,7 +43,9 @@ impl Decode<'_> for TsUrbResult { if urb_size < ACTUAL_HEADER_SIZE { return Err(invalid_field_err!("TS_URB_RESULT_HEADER::Size", "is smaller than 8")); } - let payload = TsUrbResultPayload::decode(&mut ReadCursor::new(src.read_slice(urb_size - ACTUAL_HEADER_SIZE)))?; + let payload_size = urb_size - ACTUAL_HEADER_SIZE; + ensure_size!(in: src, size: payload_size); + let payload = TsUrbResultPayload::decode(&mut ReadCursor::new(src.read_slice(payload_size)))?; Ok(Self { header, payload }) } } @@ -426,7 +428,9 @@ impl Decode<'_> for TsUsbdInterfaceInfoResult { "is less than min reqd value of 16" )); }; - let mut src = ReadCursor::new(src.read_slice(usize::from(length) - 2)); + let remaining_length = usize::from(length) - 2 /* Length */; + ensure_size!(in: src, size: remaining_length); + let mut src = ReadCursor::new(src.read_slice(remaining_length)); let interface_number = src.read_u8(); let alternate_setting = src.read_u8(); let class = src.read_u8(); diff --git a/crates/ironrdp-rdpeusb/src/pdu/header.rs b/crates/ironrdp-rdpeusb/src/pdu/header.rs index 6f1b3121c8..38174fbb97 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/header.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/header.rs @@ -16,20 +16,20 @@ pub type MessageId = u32; #[repr(u8)] #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq)] -pub enum Mask { +pub(crate) enum Mask { /// Indicates that the [`SharedMsgHeader`] is being used in a response message. #[doc(alias = "STREAM_ID_STUB")] - StreamIdStub = 0x2, + Stub = 0x2, /// Indicates that the [`SharedMsgHeader`] is not being used in a response message. #[doc(alias = "STREAM_ID_PROXY")] - StreamIdProxy = 0x1, + Proxy = 0x1, /// Indicates that the [`SharedMsgHeader`] is being used in a message for capabilities exchange /// ([`RimExchangeCapabilityRequest`], [`RimExchangeCapabilityResponse`]). This value **MUST /// NOT** be used for any other messages. #[doc(alias = "STREAM_ID_NONE")] - StreamIdNone = 0x0, + None = 0x0, } impl From for u32 { @@ -44,9 +44,9 @@ impl TryFrom for Mask { fn try_from(value: u8) -> Result { match value { - 0x0 => Ok(Self::StreamIdNone), - 0x1 => Ok(Self::StreamIdProxy), - 0x2 => Ok(Self::StreamIdStub), + 0x0 => Ok(Self::None), + 0x1 => Ok(Self::Proxy), + 0x2 => Ok(Self::Stub), _ => Err(invalid_field_err!("try_from", "Mask", "invalid mask")), } } @@ -100,6 +100,15 @@ impl InterfaceId { /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a7ea1b33-80bb-4197-a502-ee62 pub const NOTIFY_SERVER: Self = Self(0x3); + + #[inline] + pub(crate) fn with_mask(self, mask: Mask) -> u32 { + self.0 | (u32::from(mask) << 30) + } + + const fn from_raw(value: u32) -> Self { + Self(value & 0x3F_FF_FF_FF) + } } impl TryFrom for InterfaceId { @@ -130,6 +139,12 @@ impl core::fmt::Display for InterfaceId { } } +#[inline] +pub(crate) fn unpack(id: u32) -> DecodeResult<(InterfaceId, Mask)> { + #[expect(clippy::as_conversions)] + Ok((InterfaceId::from_raw(id), Mask::try_from((id >> 30) as u8)?)) +} + /// Indicates a task/function to perform. /// /// Function ID's are defined for all interfaces: @@ -166,9 +181,9 @@ pub struct FunctionId(pub(in crate::pdu) u32); impl FunctionId { pub const FIXED_PART_SIZE: usize = size_of::(); - // // Needed for QI_REQ and QI_RSP + // Needed for QI_REQ and QI_RSP // - // /// Release the given interface ID. + /// Release the given interface ID. pub const RIMCALL_RELEASE: Self = Self(0x00000001); pub const RIMCALL_QUERYINTERFACE: Self = Self(0x00000002); @@ -209,15 +224,9 @@ impl FunctionId { pub const CHANNEL_CREATED: Self = Self(0x100); } -impl TryFrom for FunctionId { - type Error = DecodeError; - - fn try_from(value: u32) -> Result { - if matches!(value, 0x001 | 0x002 | 0x100..=0x107) { - Ok(Self(value)) - } else { - Err(invalid_field_err!("FunctionId", "invalid FunctionId")) - } +impl From for FunctionId { + fn from(value: u32) -> Self { + Self(value) } } @@ -233,8 +242,7 @@ impl core::fmt::Display for FunctionId { #[doc(alias = "SHARED_MSG_HEADER")] #[derive(Debug, PartialEq, Clone)] pub struct SharedMsgHeader { - pub interface_id: InterfaceId, - pub mask: Mask, + pub iface_id: u32, pub msg_id: MessageId, pub function_id: Option, } @@ -243,14 +251,22 @@ impl SharedMsgHeader { pub const SIZE_RSP: usize = size_of::(/* InterfaceId, Mask */) + size_of::(); pub const SIZE_REQ: usize = Self::SIZE_RSP + FunctionId::FIXED_PART_SIZE; + + pub(super) fn decode_with_function_id(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: Self::SIZE_REQ); + Ok(Self { + iface_id: src.read_u32(), + msg_id: src.read_u32(), + function_id: Some(FunctionId(src.read_u32())), + }) + } } impl Encode for SharedMsgHeader { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - let first32 = u32::from(self.interface_id) | (u32::from(self.mask) << 30); - dst.write_u32(first32); + dst.write_u32(self.iface_id); dst.write_u32(self.msg_id); if let Some(id) = self.function_id { @@ -276,33 +292,10 @@ impl Encode for SharedMsgHeader { impl Decode<'_> for SharedMsgHeader { fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { ensure_size!(in: src, size: Self::SIZE_RSP ); - - let first32 = src.read_u32(); - let interface_id = InterfaceId::try_from(first32 & 0x3F_FF_FF_FF)?; - #[expect(clippy::as_conversions)] - let mask = Mask::try_from((first32 >> 30) as u8)?; - - let msg_id = src.read_u32(); - - let function_id = match mask { - Mask::StreamIdStub => None, - Mask::StreamIdProxy => { - ensure_size!(in: src, size: FunctionId::FIXED_PART_SIZE); - let id = FunctionId::try_from(src.read_u32())?; - Some(id) - } - Mask::StreamIdNone => { - ensure_size!(in: src, size: FunctionId::FIXED_PART_SIZE); - // either 0x100 (FunctionId) or 0x001 (CapabilityValue) - (src.peek_u32() == FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST.0).then(|| FunctionId(src.read_u32())) - } - }; - - Ok(SharedMsgHeader { - interface_id, - mask, - msg_id, - function_id, + Ok(Self { + iface_id: src.read_u32(), + msg_id: src.read_u32(), + function_id: None, }) } } diff --git a/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs b/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs new file mode 100644 index 0000000000..4624c1241f --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs @@ -0,0 +1,179 @@ +//! Messages specific to [Interface Manipulation][1] interface. +//! +//! MS-RDPEUSB utilizes the same Interface Query and Interface Release messages that are defined in +//! [MS-RDPEXPS][2]. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6dd37383-9aed-4f9e-ba74-febe3a21f0f5 +//! [2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/ebe401f0-f22e-4de4-9cd3-2a55e5493500 + +use ironrdp_core::{Decode, Encode, ensure_fixed_part_size, ensure_size, invalid_field_err}; + +use crate::pdu::header::{FunctionId, MessageId, SharedMsgHeader}; + +/// [\[MS-RDPEXPS\] 2.2.2.2 Interface Release (IFACE_RELEASE)][1] message. +/// +/// One-way message that terminates an interface's lifetime. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/5db96fd4-617f-432f-b4ec-58f75564eb06 +#[doc(alias = "IFACE_RELEASE")] +#[derive(Debug, PartialEq)] +pub struct InterfaceRelease { + pub iface_id: u32, + pub msg_id: MessageId, +} + +impl InterfaceRelease { + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.iface_id, + msg_id: self.msg_id, + function_id: Some(FunctionId::RIMCALL_RELEASE), + } + } + + pub(super) fn from_header(header: SharedMsgHeader) -> Self { + Self { + iface_id: header.iface_id, + msg_id: header.msg_id, + } + } +} + +impl Decode<'_> for InterfaceRelease { + fn decode(src: &mut ironrdp_core::ReadCursor<'_>) -> ironrdp_core::DecodeResult { + ensure_fixed_part_size!(in: src); + let iface_id = src.read_u32(); + let msg_id = src.read_u32(); + if FunctionId(src.read_u32()) != FunctionId::RIMCALL_RELEASE { + return Err(invalid_field_err!( + "SHARED_MSG_HEADER::FunctionId", + "must be 0x1 (RIMCALL_RELEASE)" + )); + } + + Ok(Self { iface_id, msg_id }) + } +} + +impl Encode for InterfaceRelease { + fn encode(&self, dst: &mut ironrdp_core::WriteCursor<'_>) -> ironrdp_core::EncodeResult<()> { + self.header().encode(dst) + } + + fn name(&self) -> &'static str { + "IFACE_RELEASE" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEXPS\] 2.2.2.1.1 Query Interface Request (QI_REQ)][1] message. +/// +/// Request a new interface ID. Per [MS-RDPEXPS § 3.1.5.2.1.1] the server MUST NOT send `QI_REQ`; +/// MS-RDPEUSB inherits this restriction. We decode incoming `QI_REQ` for ecosystem tolerance and +/// answer with a failure [`QueryInterfaceFailureResponse`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/10757445-d7dd-4602-b75f-772540c01a5d +#[doc(alias = "QI_REQ")] +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct QueryInterfaceRequest { + pub iface_id: u32, + pub msg_id: MessageId, + pub new_interface_guid: u128, +} + +impl QueryInterfaceRequest { + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ + 16 /* NewInterfaceGUID */; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.iface_id, + msg_id: self.msg_id, + function_id: Some(FunctionId::RIMCALL_QUERYINTERFACE), + } + } + + pub(super) fn decode( + src: &mut ironrdp_core::ReadCursor<'_>, + header: SharedMsgHeader, + ) -> ironrdp_core::DecodeResult { + ensure_size!(in: src, size: 16); + Ok(Self { + iface_id: header.iface_id, + msg_id: header.msg_id, + new_interface_guid: src.read_u128(), + }) + } +} + +impl Encode for QueryInterfaceRequest { + fn encode(&self, dst: &mut ironrdp_core::WriteCursor<'_>) -> ironrdp_core::EncodeResult<()> { + self.header().encode(dst)?; + ensure_size!(in: dst, size: 16); + dst.write_u128(self.new_interface_guid); + Ok(()) + } + + fn name(&self) -> &'static str { + "QI_REQ" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEXPS\] 2.2.2.1.2 Query Interface Response (QI_RSP)][1] — **failure** variant. +/// +/// Per [MS-RDPEXPS § 3.1.5.2.1.2], on receiving a `QI_REQ` the receiver SHOULD return the failure +/// version of `QI_RSP` — a `QI_RSP` **omitting** the optional `NewInterfaceId` field. The +/// originating side MUST interpret this as "interface not supported". +/// +/// We never advertise any negotiable interface, so we always reply with this failure variant; +/// there is no `QueryInterfaceSuccessResponse` type. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/bcf53670-4db2-450a-b53e-879756ca18a8 +#[doc(alias = "QI_RSP")] +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct QueryInterfaceFailureResponse { + pub iface_id: u32, + pub msg_id: MessageId, +} + +impl QueryInterfaceFailureResponse { + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_RSP; + + pub fn for_request(req: &QueryInterfaceRequest) -> Self { + Self { + iface_id: req.iface_id, + msg_id: req.msg_id, + } + } + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.iface_id, + msg_id: self.msg_id, + function_id: None, + } + } +} + +impl Encode for QueryInterfaceFailureResponse { + fn encode(&self, dst: &mut ironrdp_core::WriteCursor<'_>) -> ironrdp_core::EncodeResult<()> { + // Failure QI_RSP = SHARED_MSG_HEADER only, no body. + self.header().encode(dst) + } + + fn name(&self) -> &'static str { + "QI_RSP" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/mod.rs index 715fb3d2bd..1833486b88 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/mod.rs @@ -1,14 +1,19 @@ //! Message packets from [\[MS-RDPEUSB\]][1], and helpers for encoding and decoding from wire. //! -//! These messages are divided into [`UrbdrcServerPdu`] and [`UrbdrcClientPdu`]. +//! These messages are split into four enums by direction (server, client) and DVC role +//! (the singleton control DVC vs. per-device DVCs): [`UrbdrcServerControlPdu`], +//! [`UrbdrcServerDevicePdu`], [`UrbdrcClientControlPdu`], [`UrbdrcClientDevicePdu`]. //! //! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a1004d0e-99e9-4968-894b-0b924ef2f125 -use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, invalid_field_err}; +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, +}; use crate::pdu::caps::{RimExchangeCapabilityRequest, RimExchangeCapabilityResponse}; use crate::pdu::completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}; -use crate::pdu::header::{FunctionId, InterfaceId, Mask, SharedMsgHeader}; +use crate::pdu::header::{FunctionId, InterfaceId, Mask, SharedMsgHeader, unpack}; +use crate::pdu::iface_manipulation::{InterfaceRelease, QueryInterfaceRequest}; use crate::pdu::notify::ChannelCreated; use crate::pdu::sink::{AddDevice, AddVirtualChannel}; use crate::pdu::usb_dev::{ @@ -19,15 +24,52 @@ use crate::pdu::usb_dev::{ pub mod caps; pub mod completion; pub mod header; +pub mod iface_manipulation; pub mod notify; pub mod sink; pub mod usb_dev; pub mod utils; /// A message sent from the server to the client. -pub enum UrbdrcServerPdu { +pub enum UrbdrcServerControlPdu { Caps(RimExchangeCapabilityRequest), ChanCreated(ChannelCreated), + IfaceRelease(InterfaceRelease), + QueryIfaceReq(QueryInterfaceRequest), +} + +impl UrbdrcServerControlPdu { + fn decode_caps(src: &mut ReadCursor<'_>, f_id: FunctionId, header: SharedMsgHeader) -> DecodeResult { + match f_id { + FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST => { + RimExchangeCapabilityRequest::decode(src, header).map(Self::Caps) + } + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid RIM_EXCHANGE_CAPABILITY_REQUEST header" + )), + } + } + + fn decode_notification(src: &mut ReadCursor<'_>, f_id: FunctionId, header: SharedMsgHeader) -> DecodeResult { + match f_id { + FunctionId::CHANNEL_CREATED => ChannelCreated::decode(src, header).map(Self::ChanCreated), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid CHANNEL_CREATED header" + )), + } + } +} + +pub enum UrbdrcServerDevicePdu { + ChanCreated(ChannelCreated), + IfaceRelease(InterfaceRelease), + QueryIfaceReq(QueryInterfaceRequest), CancelReq(CancelRequest), RegReqCb(RegisterRequestCallback), IoCtl(IoControl), @@ -38,73 +80,94 @@ pub enum UrbdrcServerPdu { Retract(RetractDevice), } -impl Decode<'_> for UrbdrcServerPdu { - // TODO: QI_RSP +impl UrbdrcServerDevicePdu { + fn decode_notification(src: &mut ReadCursor<'_>, f_id: FunctionId, header: SharedMsgHeader) -> DecodeResult { + match f_id { + FunctionId::CHANNEL_CREATED => ChannelCreated::decode(src, header).map(Self::ChanCreated), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid CHANNEL_CREATED header" + )), + } + } +} + +impl Decode<'_> for UrbdrcServerControlPdu { fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { - let header = SharedMsgHeader::decode(src)?; - let f_id = header - .function_id - .ok_or_else(|| invalid_field_err!("SHARED_MSG_HEADER::FunctionId", "is absent"))?; - - match header.interface_id { - InterfaceId::CAPABILITIES => { - if f_id == FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST && header.mask == Mask::StreamIdNone { - RimExchangeCapabilityRequest::decode(src, header).map(Self::Caps) - } else { - Err(invalid_field_err!( - "SHARED_MSG_HEADER", - "invalid RIM_EXCHANGE_CAPABILITY_REQUEST header" - )) + let header = SharedMsgHeader::decode_with_function_id(src)?; + let f_id = header.function_id.expect("missing function id"); + + match unpack(header.iface_id)? { + (InterfaceId::CAPABILITIES, Mask::None) => Self::decode_caps(src, f_id, header), + (InterfaceId::NOTIFY_CLIENT, Mask::Proxy) => Self::decode_notification(src, f_id, header), + _ => Err(invalid_field_err!("SHARED_MSG_HEADER", "invalid header")), + } + } +} + +impl Decode<'_> for UrbdrcServerDevicePdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = SharedMsgHeader::decode_with_function_id(src)?; + let f_id = header.function_id.expect("missing function id"); + + match unpack(header.iface_id)? { + (InterfaceId::NOTIFY_CLIENT, Mask::Proxy) => Self::decode_notification(src, f_id, header), + (udev_iface, Mask::Proxy) => match f_id { + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => { + QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq) } - } - InterfaceId::NOTIFY_CLIENT => { - if f_id == FunctionId::CHANNEL_CREATED && header.mask == Mask::StreamIdProxy { - ChannelCreated::decode(src, header).map(Self::ChanCreated) - } else { - Err(invalid_field_err!( - "SHARED_MSG_HEADER", - "invalid CHANNEL_CREATED header" - )) + FunctionId::CANCEL_REQUEST => { + CancelRequest::decode(src, header.msg_id, udev_iface).map(Self::CancelReq) } - } - InterfaceId::NOTIFY_SERVER | InterfaceId::DEVICE_SINK => Err(invalid_field_err!( - "SHARED_MSG_HEADER", - "reserved interface ID is not valid for server-to-client messages" - )), - _udev_iface => { - if header.mask != Mask::StreamIdProxy { - return Err(invalid_field_err!( - "SHARED_MSG_HEADER::Mask", - "is not 0x1 (STREAM_ID_PROXY)" - )); + FunctionId::REGISTER_REQUEST_CALLBACK => { + RegisterRequestCallback::decode(src, header.msg_id, udev_iface).map(Self::RegReqCb) } - match f_id { - FunctionId::CANCEL_REQUEST => CancelRequest::decode(src, header).map(Self::CancelReq), - FunctionId::REGISTER_REQUEST_CALLBACK => { - RegisterRequestCallback::decode(src, header).map(Self::RegReqCb) - } - FunctionId::IO_CONTROL => IoControl::decode(src, header).map(Self::IoCtl), - FunctionId::INTERNAL_IO_CONTROL => InternalIoControl::decode(src, header).map(Self::InternalIoCtl), - FunctionId::QUERY_DEVICE_TEXT => QueryDeviceText::decode(src, header).map(Self::DevText), - FunctionId::TRANSFER_IN_REQUEST => TransferInRequest::decode(src, header).map(Self::TransferIn), - FunctionId::TRANSFER_OUT_REQUEST => TransferOutRequest::decode(src, header).map(Self::TransferOut), - FunctionId::RETRACT_DEVICE => RetractDevice::decode(src, header).map(Self::Retract), - _ => Err(invalid_field_err!( - "SHARED_MSG_HEADER::FunctionId", - "unsupported function id for USB device interface" - )), + FunctionId::IO_CONTROL => IoControl::decode(src, header.msg_id, udev_iface).map(Self::IoCtl), + FunctionId::INTERNAL_IO_CONTROL => { + InternalIoControl::decode(src, header.msg_id, udev_iface).map(Self::InternalIoCtl) } - } + FunctionId::QUERY_DEVICE_TEXT => { + QueryDeviceText::decode(src, header.msg_id, udev_iface).map(Self::DevText) + } + FunctionId::TRANSFER_IN_REQUEST => { + TransferInRequest::decode(src, header.msg_id, udev_iface).map(Self::TransferIn) + } + FunctionId::TRANSFER_OUT_REQUEST => { + TransferOutRequest::decode(src, header.msg_id, udev_iface).map(Self::TransferOut) + } + FunctionId::RETRACT_DEVICE => RetractDevice::decode(src, header.msg_id, udev_iface).map(Self::Retract), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER::FunctionId", + "unsupported function id for USB device interface" + )), + }, + _ => Err(invalid_field_err!("SHARED_MSG_HEADER", "invalid header")), } } } -macro_rules! fill_server_pdu_arms { +macro_rules! fill_server_ctl_pdu_arms { ($pdu:expr, $($tokens:tt)*) => {{ - use UrbdrcServerPdu::*; - match <&UrbdrcServerPdu>::from($pdu) { + use UrbdrcServerControlPdu::*; + match <&UrbdrcServerControlPdu>::from($pdu) { Caps(rim_exchange_capability_request) => rim_exchange_capability_request$($tokens)*, ChanCreated(channel_created) => channel_created$($tokens)*, + IfaceRelease(iface_release) => iface_release$($tokens)*, + QueryIfaceReq(query_iface_req) => query_iface_req$($tokens)*, + } + }}; +} + +macro_rules! fill_server_dev_pdu_arms { + ($pdu:expr, $($tokens:tt)*) => {{ + use UrbdrcServerDevicePdu::*; + match <&UrbdrcServerDevicePdu>::from($pdu) { + ChanCreated(channel_created) => channel_created$($tokens)*, + IfaceRelease(iface_release) => iface_release$($tokens)*, + QueryIfaceReq(query_iface_req) => query_iface_req$($tokens)*, CancelReq(cancel_request) => cancel_request$($tokens)*, RegReqCb(register_request_callback) => register_request_callback$($tokens)*, IoCtl(io_control) => io_control$($tokens)*, @@ -117,98 +180,185 @@ macro_rules! fill_server_pdu_arms { }}; } -impl Encode for UrbdrcServerPdu { +impl Encode for UrbdrcServerControlPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + fill_server_ctl_pdu_arms!(self, .encode(dst)) + } + + fn name(&self) -> &'static str { + fill_server_ctl_pdu_arms!(self, .name()) + } + + fn size(&self) -> usize { + fill_server_ctl_pdu_arms!(self, .size()) + } +} + +impl Encode for UrbdrcServerDevicePdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - fill_server_pdu_arms!(self, .encode(dst)) + fill_server_dev_pdu_arms!(self, .encode(dst)) } fn name(&self) -> &'static str { - fill_server_pdu_arms!(self, .name()) + fill_server_dev_pdu_arms!(self, .name()) } fn size(&self) -> usize { - fill_server_pdu_arms!(self, .size()) + fill_server_dev_pdu_arms!(self, .size()) } } /// A message sent from the client to the server. -pub enum UrbdrcClientPdu { +pub enum UrbdrcClientControlPdu { Caps(RimExchangeCapabilityResponse), + ChanCreated(ChannelCreated), AddChan(AddVirtualChannel), - AddDev(AddDevice), + IfaceRelease(InterfaceRelease), + QueryIfaceReq(QueryInterfaceRequest), +} + +pub enum UrbdrcClientDevicePdu { ChanCreated(ChannelCreated), + AddDev(AddDevice), DevTextRsp(QueryDeviceTextRsp), IoctlComp(IoControlCompletion), UrbComp(UrbCompletion), UrbCompNoData(UrbCompletionNoData), + IfaceRelease(InterfaceRelease), + QueryIfaceReq(QueryInterfaceRequest), +} + +impl UrbdrcClientControlPdu { + fn decode_sink(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* function id */); + let f_id = FunctionId(src.read_u32()); + match f_id { + FunctionId::ADD_VIRTUAL_CHANNEL => AddVirtualChannel::decode(src, header).map(Self::AddChan), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid function id in DEVICE_SINK" + )), + } + } + fn decode_notification(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* function id */); + let f_id = FunctionId(src.read_u32()); + match f_id { + FunctionId::CHANNEL_CREATED => ChannelCreated::decode(src, header).map(Self::ChanCreated), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid function id in CHANNEL_CREATED" + )), + } + } } -impl Decode<'_> for UrbdrcClientPdu { +impl Decode<'_> for UrbdrcClientControlPdu { fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { let header = SharedMsgHeader::decode(src)?; - match header.interface_id { - InterfaceId::CAPABILITIES => { - if header.function_id.is_none() && header.mask == Mask::StreamIdNone { - RimExchangeCapabilityResponse::decode(src, header).map(Self::Caps) - } else { - Err(invalid_field_err!( - "SHARED_MSG_HEADER", - "invalid RIM_EXCHANGE_CAPABILITY_RESPONSE header" - )) - } - } - InterfaceId::DEVICE_SINK => match (header.function_id, header.mask) { - (Some(FunctionId::ADD_VIRTUAL_CHANNEL), Mask::StreamIdProxy) => { - AddVirtualChannel::decode(src, header).map(Self::AddChan) - } - (Some(FunctionId::ADD_DEVICE), Mask::StreamIdProxy) => AddDevice::decode(src, header).map(Self::AddDev), - _ => Err(invalid_field_err!( - "SHARED_MSG_HEADER", - "invalid Device Sink interface header" - )), - }, - InterfaceId::NOTIFY_SERVER => { - if header.function_id == Some(FunctionId::CHANNEL_CREATED) && header.mask == Mask::StreamIdProxy { - ChannelCreated::decode(src, header).map(Self::ChanCreated) - } else { - Err(invalid_field_err!( - "SHARED_MSG_HEADER", - "invalid CHANNEL_CREATED header" - )) - } + + match unpack(header.iface_id)? { + (InterfaceId::CAPABILITIES, Mask::None) => { + RimExchangeCapabilityResponse::decode(src, header).map(Self::Caps) } - InterfaceId::NOTIFY_CLIENT => Err(invalid_field_err!( + (InterfaceId::DEVICE_SINK, Mask::Proxy) => Self::decode_sink(src, header), + (InterfaceId::NOTIFY_SERVER, Mask::Proxy) => Self::decode_notification(src, header), + _ => Err(invalid_field_err!("SHARED_MSG_HEADER", "invalid header")), + } + } +} + +impl UrbdrcClientDevicePdu { + fn decode_sink(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* function id */); + let f_id = FunctionId(src.read_u32()); + match f_id { + FunctionId::ADD_DEVICE => AddDevice::decode(src, header).map(Self::AddDev), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( "SHARED_MSG_HEADER", - "reserved interface ID is not valid for client-to-server messages" + "invalid function id in DEVICE_SINK" )), - _id => match (header.function_id, header.mask) { - (None, Mask::StreamIdStub) => QueryDeviceTextRsp::decode(src, header).map(Self::DevTextRsp), - (Some(FunctionId::IOCONTROL_COMPLETION), Mask::StreamIdProxy) => { - IoControlCompletion::decode(src, header).map(Self::IoctlComp) - } - (Some(FunctionId::URB_COMPLETION), Mask::StreamIdProxy) => { - UrbCompletion::decode(src, header).map(Self::UrbComp) - } - (Some(FunctionId::URB_COMPLETION_NO_DATA), Mask::StreamIdProxy) => { - UrbCompletionNoData::decode(src, header).map(Self::UrbCompNoData) + } + } + fn decode_notification(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* function id */); + let f_id = FunctionId(src.read_u32()); + match f_id { + FunctionId::CHANNEL_CREATED => ChannelCreated::decode(src, header).map(Self::ChanCreated), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid function id in CHANNEL_CREATED" + )), + } + } +} + +impl Decode<'_> for UrbdrcClientDevicePdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = SharedMsgHeader::decode(src)?; + + match unpack(header.iface_id)? { + (InterfaceId::DEVICE_SINK, Mask::Proxy) => Self::decode_sink(src, header), + (InterfaceId::NOTIFY_SERVER, Mask::Proxy) => Self::decode_notification(src, header), + (udev_iface, Mask::Stub) => { + QueryDeviceTextRsp::decode(src, header.msg_id, udev_iface).map(Self::DevTextRsp) + } + (udev_iface, Mask::Proxy) => { + ensure_size!(in: src, size: 4 /* function id */); + match FunctionId(src.read_u32()) { + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => { + QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq) + } + FunctionId::IOCONTROL_COMPLETION => { + IoControlCompletion::decode(src, header.msg_id, udev_iface).map(Self::IoctlComp) + } + FunctionId::URB_COMPLETION => { + UrbCompletion::decode(src, header.msg_id, udev_iface).map(Self::UrbComp) + } + FunctionId::URB_COMPLETION_NO_DATA => { + UrbCompletionNoData::decode(src, header.msg_id, udev_iface).map(Self::UrbCompNoData) + } + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER::InterfaceId", + "unknown interface id" + )), } - _ => Err(invalid_field_err!( - "SHARED_MSG_HEADER::InterfaceId", - "unknown interface id" - )), - }, + } + _ => Err(invalid_field_err!("SHARED_MSG_HEADER", "invalid header")), } } } -macro_rules! fill_client_pdu_arms { +macro_rules! fill_client_ctl_pdu_arms { ($pdu:expr, $($tokens:tt)*) => {{ - use UrbdrcClientPdu::*; - match <&UrbdrcClientPdu>::from($pdu) { + use UrbdrcClientControlPdu::*; + match <&UrbdrcClientControlPdu>::from($pdu) { Caps(rim_exchange_capability_response) => rim_exchange_capability_response$($tokens)*, AddChan(add_virtual_channel) => add_virtual_channel$($tokens)*, - AddDev(add_device) => add_device$($tokens)*, ChanCreated(channel_created) => channel_created$($tokens)*, + IfaceRelease(iface_release) => iface_release$($tokens)*, + QueryIfaceReq(query_iface_req) => query_iface_req$($tokens)*, + } + }}; +} + +macro_rules! fill_client_dev_pdu_arms { + ($pdu:expr, $($tokens:tt)*) => {{ + use UrbdrcClientDevicePdu::*; + match <&UrbdrcClientDevicePdu>::from($pdu) { + ChanCreated(channel_created) => channel_created$($tokens)*, + IfaceRelease(iface_release) => iface_release$($tokens)*, + QueryIfaceReq(query_iface_req) => query_iface_req$($tokens)*, + AddDev(add_dev) => add_dev$($tokens)*, DevTextRsp(query_device_text_rsp) => query_device_text_rsp$($tokens)*, IoctlComp(iocontrol_completion) => iocontrol_completion$($tokens)*, UrbComp(urb_completion) => urb_completion$($tokens)*, @@ -217,16 +367,30 @@ macro_rules! fill_client_pdu_arms { }}; } -impl Encode for UrbdrcClientPdu { +impl Encode for UrbdrcClientControlPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + fill_client_ctl_pdu_arms!(self, .encode(dst)) + } + + fn name(&self) -> &'static str { + fill_client_ctl_pdu_arms!(self, .name()) + } + + fn size(&self) -> usize { + fill_client_ctl_pdu_arms!(self, .size()) + } +} + +impl Encode for UrbdrcClientDevicePdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - fill_client_pdu_arms!(self, .encode(dst)) + fill_client_dev_pdu_arms!(self, .encode(dst)) } fn name(&self) -> &'static str { - fill_client_pdu_arms!(self, .name()) + fill_client_dev_pdu_arms!(self, .name()) } fn size(&self) -> usize { - fill_client_pdu_arms!(self, .size()) + fill_client_dev_pdu_arms!(self, .size()) } } diff --git a/crates/ironrdp-rdpeusb/src/pdu/notify.rs b/crates/ironrdp-rdpeusb/src/pdu/notify.rs index a9ee7729bc..a01fae3de8 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/notify.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/notify.rs @@ -13,7 +13,7 @@ use ironrdp_core::{ unsupported_value_err, }; -use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader, unpack}; /// [\[MS-RDPEUSB\] 2.2.5.1 Channel Created Message (CHANNEL_CREATED)][1] packet. /// @@ -54,12 +54,12 @@ impl ChannelCreated { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: if let Direction::ToServer = self.direction { + iface_id: if let Direction::ToServer = self.direction { InterfaceId::NOTIFY_SERVER } else { InterfaceId::NOTIFY_CLIENT - }, - mask: Mask::StreamIdProxy, + } + .with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::CHANNEL_CREATED), } @@ -83,7 +83,7 @@ impl ChannelCreated { Ok(Self { msg_id: header.msg_id, - direction: match header.interface_id { + direction: match unpack(header.iface_id)?.0 { InterfaceId::NOTIFY_CLIENT => Direction::ToClient, InterfaceId::NOTIFY_SERVER => Direction::ToServer, _ => unreachable!("dispatcher must filter interface_id to NOTIFY_CLIENT/NOTIFY_SERVER"), diff --git a/crates/ironrdp-rdpeusb/src/pdu/sink.rs b/crates/ironrdp-rdpeusb/src/pdu/sink.rs index 0f092c0ccf..e28148deaa 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/sink.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/sink.rs @@ -9,7 +9,7 @@ use alloc::format; use ironrdp_core::{ Decode, DecodeOwned as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, - ensure_size, other_err, unsupported_value_err, + ensure_size, invalid_field_err, unsupported_value_err, }; use ironrdp_pdu::utils::strict_sum; use ironrdp_str::multi_sz::MultiSzString; @@ -33,8 +33,7 @@ impl AddVirtualChannel { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: InterfaceId::DEVICE_SINK, - mask: Mask::StreamIdProxy, + iface_id: InterfaceId::DEVICE_SINK.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::ADD_VIRTUAL_CHANNEL), } @@ -84,8 +83,7 @@ impl AddDevice { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: InterfaceId::DEVICE_SINK, - mask: Mask::StreamIdProxy, + iface_id: InterfaceId::DEVICE_SINK.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::ADD_DEVICE), } @@ -100,11 +98,10 @@ impl AddDevice { ensure_size!(in: src, size: InterfaceId::FIXED_PART_SIZE); let usb_device = match src.read_u32() { - interface_id @ 0x0..=0x3 => return Err(unsupported_value_err!("UsbDevice", format!("{interface_id}"))), - value @ 0x4..=0x3F_FF_FF_FF => InterfaceId::try_from(value).map_err(|e| - // Only a map_err and not expect (value clamped) cause clippy complains - other_err!(source: e))?, - value @ 0x40_00_00_00.. => return Err(unsupported_value_err!("UsbDevice", format!("{value}"))), + 0x0..=0x3 => { + return Err(invalid_field_err!("UsbDevice", "conflict with default interfaces")); + } + value => InterfaceId::try_from(value)?, }; let device_instance_id = Cch32String::decode_owned(src)?; @@ -212,10 +209,24 @@ impl UsbDeviceCaps { #[expect(clippy::as_conversions)] pub const FIXED_PART_SIZE: usize = Self::CB_SIZE as usize; + + const fn check_device_speed( + usb_bus_iface_ver: UsbBusIfaceVer, + device_speed: DeviceSpeed, + ) -> Result<(), &'static str> { + if matches!(usb_bus_iface_ver, UsbBusIfaceVer::V0) && matches!(device_speed, DeviceSpeed::HighSpeed) { + Err("must be 0x00000000 when UsbBusInterfaceVersion is 0x00000000") + } else { + Ok(()) + } + } } impl Encode for UsbDeviceCaps { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + Self::check_device_speed(self.usb_bus_iface_ver, self.device_speed) + .map_err(|reason| invalid_field_err!("USB_DEVICE_CAPABILITIES::DeviceIsHighSpeed", reason))?; + ensure_fixed_part_size!(in: dst); dst.write_u32(Self::CB_SIZE); @@ -280,6 +291,8 @@ impl Decode<'_> for UsbDeviceCaps { 0x1 => DeviceSpeed::HighSpeed, value => return Err(unsupported_value_err!("DeviceIsHighSpeed", format!("{value}"))), }; + Self::check_device_speed(usb_bus_iface_ver, device_speed) + .map_err(|reason| invalid_field_err!("USB_DEVICE_CAPABILITIES::DeviceIsHighSpeed", reason))?; let no_ack_isoch_write_jitter_buf_size = match src.read_u32() { 0 => NoAckIsochWriteJitterBufSizeInMs::TS_URB_ISOCH_TRANSFER_NOT_SUPPORTED, value @ 10..=512 => NoAckIsochWriteJitterBufSizeInMs(value), diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs index 3b6e9962c8..7fa1ec0567 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs @@ -8,13 +8,13 @@ use alloc::format; use alloc::vec::Vec; use ironrdp_core::{ - DecodeError, DecodeOwned as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, + Decode as _, DecodeOwned as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, unsupported_value_err, }; use ironrdp_str::prefixed::Cch32String; use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; -use crate::pdu::usb_dev::ts_urb::{TransferDirection, TsUrb}; +use crate::pdu::usb_dev::ts_urb::{TsUrbIn, TsUrbOut}; use crate::pdu::utils::{HResult, RequestId, RequestIdIoctl}; #[cfg(doc)] use crate::pdu::{ @@ -44,20 +44,19 @@ impl CancelRequest { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdProxy, + iface_id: self.udev_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::CANCEL_REQUEST), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); let req_id = src.read_u32(); Ok(Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, req_id, }) } @@ -98,30 +97,32 @@ pub struct RegisterRequestCallback { impl RegisterRequestCallback { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdProxy, + iface_id: self.udev_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::REGISTER_REQUEST_CALLBACK), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: 4 /* NumRequestCompletion */); let request_completion = match src.read_u32() { 0x0 => None, _ => { ensure_size!(in: src, size: InterfaceId::FIXED_PART_SIZE); - let interface = InterfaceId::try_from(src.read_u32()).map_err(|source| { - let e: DecodeError = - invalid_field_err!("REGISTER_REQUEST_CALLBACK::RequestCompletion", "more than 30 bits"); - e.with_source(source) - })?; - Some(interface) + match src.read_u32() { + 0x0..=0x3 => { + return Err(invalid_field_err!( + "RequestCompletion", + "conflict with default interfaces" + )); + } + value => Some(InterfaceId::try_from(value)?), + } } }; Ok(Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, request_completion, }) } @@ -176,8 +177,7 @@ impl IoControl { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdProxy, + iface_id: self.udev_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::IO_CONTROL), } @@ -209,7 +209,7 @@ impl IoControl { } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_MIN_SIZE); let ioctl_code = match src.read_u32() { 0x220_007 => IoctlInternalUsb::ResetPort, @@ -224,12 +224,13 @@ impl IoControl { let input_buffer_size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; ensure_size!(in: src, size: input_buffer_size /* InputBuffer */ + 4 /* OutputBufferSize */ + 4 /* RequestId */); + // TODO: size limit let input_buffer = src.read_slice(input_buffer_size).to_vec(); let output_buffer_size = src.read_u32(); let req_id = src.read_u32(); let io_control = Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, ioctl_code, input_buffer, output_buffer_size, @@ -420,14 +421,13 @@ impl InternalIoControl { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdProxy, + iface_id: self.udev_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::INTERNAL_IO_CONTROL), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); { @@ -458,8 +458,8 @@ impl InternalIoControl { let req_id = src.read_u32(); Ok(Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, ioctl_code: UsbInternalIoctlCode::IoctlTsusbgdIoctlUsbdiQueryBusTime, input_buffer: Vec::new(), output_buffer_size, @@ -503,7 +503,7 @@ impl Encode for InternalIoControl { pub struct QueryDeviceText { pub msg_id: MessageId, pub udev_iface: InterfaceId, - pub text_type: DeviceTextType, + pub text_type: u32, // TODO: Find out if MS-LCID and USB language ID's are same pub locale_id: u32, } @@ -515,31 +515,21 @@ impl QueryDeviceText { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdProxy, + iface_id: self.udev_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::QUERY_DEVICE_TEXT), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); - let text_type = match src.read_u32() { - 0 => DeviceTextType::Description, - 1 => DeviceTextType::LocationInformation, - value => { - return Err(unsupported_value_err!( - "QUERY_DEVICE_TEXT::TextType", - format!("{value}") - )); - } - }; + let text_type = src.read_u32(); let locale_id = src.read_u32(); Ok(Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, text_type, locale_id, }) @@ -551,8 +541,7 @@ impl Encode for QueryDeviceText { ensure_fixed_part_size!(in: dst); self.header().encode(dst)?; - #[expect(clippy::as_conversions)] - dst.write_u32(self.text_type as u32); + dst.write_u32(self.text_type); dst.write_u32(self.locale_id); Ok(()) @@ -567,20 +556,6 @@ impl Encode for QueryDeviceText { } } -/// Indicates what kind of text/information is to be requested. -#[repr(u32)] -#[doc(alias = "DEVICE_TEXT_TYPE")] -#[derive(Debug, PartialEq, Clone, Copy)] -pub enum DeviceTextType { - /// Basic description like manufacturer or product name. - #[doc(alias = "DeviceTextDescription")] - Description = 0x0, - - /// Information such as where/what is the device connected to (bus or device number). - #[doc(alias = "DeviceTextLocationInformation")] - LocationInformation = 0x1, -} - /// [\[MS-RDPEUSB\] 2.2.6.6 Query Device Text Response Message (QUERY_DEVICE_TEXT_RSP)][1] message. /// /// Sent from the client in response to a [`QueryDeviceText`] message sent by the server. @@ -598,22 +573,21 @@ pub struct QueryDeviceTextRsp { impl QueryDeviceTextRsp { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdStub, + iface_id: self.udev_iface.with_mask(Mask::Stub), msg_id: self.msg_id, function_id: None, } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { let device_description = Cch32String::decode_owned(src)?; ensure_size!(in: src, size: 4 /* HResult */); let hresult = src.read_u32(); Ok(Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, device_description, hresult, }) @@ -671,22 +645,21 @@ impl Encode for QueryDeviceTextRsp { pub struct TransferInRequest { pub msg_id: MessageId, pub udev_iface: InterfaceId, - pub ts_urb: TsUrb, + pub ts_urb: TsUrbIn, pub output_buffer_size: u32, } impl TransferInRequest { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdProxy, + iface_id: self.udev_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::TRANSFER_IN_REQUEST), } } pub fn check_output_buffer_size(&self) -> Result<(), &'static str> { - use TsUrb::*; + use TsUrbIn::*; match self.ts_urb { SelectConfig(_) if self.output_buffer_size != 0 => { @@ -721,18 +694,19 @@ impl TransferInRequest { } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: 4 /* CbTsUrb */); let cb_ts_urb = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; - let ts_urb = TsUrb::decode(&mut ReadCursor::new(src.read_slice(cb_ts_urb)), TransferDirection::In)?; + ensure_size!(in: src, size: cb_ts_urb); + let ts_urb = TsUrbIn::decode(&mut ReadCursor::new(src.read_slice(cb_ts_urb)))?; ensure_size!(in: src, size: 4 /* OutputBufferSize */); let output_buffer_size = src.read_u32(); let transfer_in_req = Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, ts_urb, output_buffer_size, }; @@ -753,7 +727,7 @@ impl Encode for TransferInRequest { self.header().encode(dst)?; dst.write_u32(self.ts_urb.size().try_into().map_err(|e| other_err!(source: e))?); - self.ts_urb.encode(dst, TransferDirection::In)?; + self.ts_urb.encode(dst)?; dst.write_u32(self.output_buffer_size); Ok(()) @@ -781,35 +755,37 @@ impl Encode for TransferInRequest { pub struct TransferOutRequest { pub msg_id: MessageId, pub udev_iface: InterfaceId, - pub ts_urb: TsUrb, + pub ts_urb: TsUrbOut, pub output_buffer: Vec, } impl TransferOutRequest { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdProxy, + iface_id: self.udev_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::TRANSFER_OUT_REQUEST), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { let ts_urb = { ensure_size!(in: src, size: 4 /* CbTsUrb */); let cb_ts_urb = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, size: cb_ts_urb); let mut src = ReadCursor::new(src.read_slice(cb_ts_urb)); - TsUrb::decode(&mut src, TransferDirection::Out)? + TsUrbOut::decode(&mut src)? }; ensure_size!(in: src, size: 4 /* OutputBufferSize */); let output_buffer_size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + // TODO: limit size + ensure_size!(in: src, size: output_buffer_size); let output_buffer = src.read_slice(output_buffer_size).to_vec(); Ok(Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, ts_urb, output_buffer, }) @@ -824,7 +800,7 @@ impl Encode for TransferOutRequest { dst.write_u32(self.ts_urb.size().try_into().map_err(|e| other_err!(source: e))?); - self.ts_urb.encode(dst, TransferDirection::Out)?; + self.ts_urb.encode(dst)?; dst.write_u32(self.output_buffer.len().try_into().map_err(|e| other_err!(source: e))?); @@ -866,14 +842,13 @@ impl RetractDevice { pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { - interface_id: self.udev_iface, - mask: Mask::StreamIdProxy, + iface_id: self.udev_iface.with_mask(Mask::Proxy), msg_id: self.msg_id, function_id: Some(FunctionId::RETRACT_DEVICE), } } - pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); let reason = src.read_u32(); @@ -883,8 +858,8 @@ impl RetractDevice { } Ok(Self { - msg_id: header.msg_id, - udev_iface: header.interface_id, + msg_id, + udev_iface, reason: UsbRetractReason::BlockedByPolicy, }) } diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs index e12eb455aa..b050d3d4ba 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs @@ -1,13 +1,15 @@ //! Packets sent to the client as part of [`TransferInRequest`] and [`TransferOutRequest`] messages //! when the server receives a URB request from its system. //! -//! A [`TsUrb`] packet is sent as part of a [`TransferInRequest`] or [`TransferOutRequest`]. +//! A [`TsUrbIn`] packet is sent as part of a [`TransferInRequest`], a [`TsUrbOut`] packet is sent +//! as part of a [`TransferOutRequest`]. +use alloc::format; use alloc::vec::Vec; use ironrdp_core::{ - Decode as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, - invalid_field_err, other_err, read_padding, write_padding, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, read_padding, unsupported_value_err, write_padding, }; use crate::pdu::usb_dev::ts_urb::utils::{SetupPacket, TsUrbHeader, TsUsbdInterfaceInfo, UrbFunction, UsbConfigDesc}; @@ -35,28 +37,11 @@ macro_rules! ensure_transfer_flag { }; } -macro_rules! ctl_desc_func_err { - (OUT, $func:expr) => {{ - invalid_field_err!( - "TRANSFER_OUT_REQUEST::TsUrb: TS_URB_CONTROL_DESCRIPTOR_REQUEST::TS_URB_HEADER::URB Function", - concat!("is ", $func, " (only used with TRANSFER_IN_REQUEST)") - ) - }}; - (IN, $func:expr) => {{ - invalid_field_err!( - "TRANSFER_IN_REQUEST::TsUrb: TS_URB_CONTROL_DESCRIPTOR_REQUEST::TS_URB_HEADER::URB Function", - concat!("is ", $func, " (only used with TRANSFER_OUT_REQUEST)") - ) - }}; -} - -/// Enumeration of all the [\[MS-RDPEUSB\] 2.2.9 TS_URB Structures][1]. +/// Enumeration of all the [\[MS-RDPEUSB\] 2.2.9 TS_URB TRANSFER_IN_REQUEST Structures][1]. /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/eed35296-3ca1-4271-bd0a-597138131b47 -#[non_exhaustive] -#[doc(alias = "TS_URB")] #[derive(Debug, PartialEq, Clone)] -pub enum TsUrb { +pub enum TsUrbIn { SelectConfig(TsUrbSelectConfig), SelectIface(TsUrbSelectInterface), PipeReq(TsUrbPipeRequest), @@ -74,262 +59,170 @@ pub enum TsUrb { CtlTransferEx(TsUrbControlTransferEx), } -impl TsUrb { - pub(crate) fn decode(src: &mut ReadCursor<'_>, direction: TransferDirection) -> DecodeResult { - use UrbFunction::*; - - let ts_urb_size = src.read_u16(/* TS_URB_HEADER::Size */); - - let header = TsUrbHeader::decode(&mut ReadCursor::new(src.read_slice(TsUrbHeader::FIXED_PART_SIZE)))?; - - if matches!(direction, TransferDirection::In) { - if header.no_ack { - return Err(invalid_field_err!( - "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::NoAck", - "is non-zero: NoAck MUST be set to zero for TRANSFER_IN_REQUEST" - )); - } - match header.func { - SetDescriptorToDevice => return Err(ctl_desc_func_err!(IN, "URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE")), - SetDescriptorToEndpoint => { - return Err(ctl_desc_func_err!(IN, "URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT")); - } - SetDescriptorToInterface => { - return Err(ctl_desc_func_err!(IN, "URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE")); - } - _ => (), - } - } - - // Weed out all URBs that are only used with TRANSFER_IN_REQUEST - if matches!(direction, TransferDirection::Out) { - macro_rules! invalid_tsurb_err { - ($reflected_ts_urb:expr) => {{ - invalid_field_err!( - "TRANSFER_OUT_REQUEST::TsUrb::TS_URB_HEADER::URB_Function", - concat!( - "URB Function reflects that TsUrb is ", - $reflected_ts_urb, - " (only used with TRANSFER_IN_REQUEST)" - ) - ) - }}; - } - - match header.func { - SelectConfiguration => return Err(invalid_tsurb_err!("TS_URB_SELECT_CONFIGURATION")), - SelectInterface => return Err(invalid_tsurb_err!("TS_URB_SELECT_CONFIGURATION")), - AbortPipe | SyncResetPipeAndClearStall | SyncResetPipe | SyncClearStall | CloseStaticStreams => { - return Err(invalid_tsurb_err!("TS_URB_PIPE_REQUEST")); - } - GetCurrentFrameNumber => return Err(invalid_tsurb_err!("TS_URB_GET_CURRENT_FRAME_NUMBER")), - - GetDescriptorFromDevice => { - return Err(ctl_desc_func_err!(OUT, "URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE")); - } - GetDescriptorFromEndpoint => { - return Err(ctl_desc_func_err!(OUT, "URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT")); - } - GetDescriptorFromInterface => { - return Err(ctl_desc_func_err!(OUT, "URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE")); - } - #[expect(unused_parens)] - (SetFeatureToDevice | SetFeatureToInterface | SetFeatureToEndpoint | SetFeatureToOther) - | (ClearFeatureToDevice | ClearFeatureToInterface | ClearFeatureToEndpoint | ClearFeatureToOther) => { - return Err(invalid_tsurb_err!("TS_URB_CONTROL_FEATURE_REQUEST")); - } - GetStatusFromDevice | GetStatusFromInterface | GetStatusFromEndpoint | GetStatusFromOther => { - return Err(invalid_tsurb_err!("TS_URB_CONTROL_GET_STATUS_REQUEST")); - } - GetConfiguration => return Err(invalid_tsurb_err!("TS_URB_CONTROL_GET_CONFIGURATION_REQUEST")), - GetInterface => return Err(invalid_tsurb_err!("TS_URB_CONTROL_GET_INTERFACE_REQUEST")), - GetMsFeatureDescriptor => return Err(invalid_tsurb_err!("TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST")), - _ => (), - } +impl Decode<'_> for TsUrbIn { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = TsUrbHeader::decode(src)?; + if header.no_ack { + return Err(invalid_field_err!( + "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::NoAck", + "is non-zero: NoAck MUST be set to zero for TRANSFER_IN_REQUEST" + )); } - let mut src = ReadCursor::new( - src.read_slice(usize::from(ts_urb_size) - size_of::(/* ts_urb_size */) - header.size()), - ); + let payload_size = usize::from(header.ts_urb_size) - header.size(); + ensure_size!(in: src, size: payload_size); + let mut src = ReadCursor::new(src.read_slice(payload_size)); let ts_urb = match header.func { - SelectConfiguration => Self::SelectConfig(TsUrbSelectConfig::decode(&mut src, header)?), + UrbFunction::URB_FUNCTION_SELECT_CONFIGURATION => { + Self::SelectConfig(TsUrbSelectConfig::decode(&mut src, header)?) + } - SelectInterface => Self::SelectIface(TsUrbSelectInterface::decode(&mut src, header)?), + UrbFunction::URB_FUNCTION_SELECT_INTERFACE => { + Self::SelectIface(TsUrbSelectInterface::decode(&mut src, header)?) + } - AbortPipe | SyncResetPipeAndClearStall | SyncResetPipe | SyncClearStall | CloseStaticStreams => { + UrbFunction::URB_FUNCTION_ABORT_PIPE + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE + | UrbFunction::URB_FUNCTION_SYNC_CLEAR_STALL + | UrbFunction::URB_FUNCTION_CLOSE_STATIC_STREAMS => { Self::PipeReq(TsUrbPipeRequest::decode(&mut src, header)?) } - GetCurrentFrameNumber => Self::GetCurFrameNum(TsUrbGetCurrFrameNum::decode(&mut src, header)?), - ControlTransfer => { + UrbFunction::URB_FUNCTION_GET_CURRENT_FRAME_NUMBER => { + Self::GetCurFrameNum(TsUrbGetCurrFrameNum::decode(&mut src, header)?) + } + UrbFunction::URB_FUNCTION_CONTROL_TRANSFER => { let urb = TsUrbControlTransfer::decode(&mut src, header)?; - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); Self::CtlTransfer(urb) } - ControlTransferEx => { + UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX => { let urb = TsUrbControlTransferEx::decode(&mut src, header)?; - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); Self::CtlTransferEx(urb) } - BulkOrInterruptTransfer | BulkOrInterruptTransferUsingChainedMdl => { + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL => { let urb = TsUrbBulkOrInterruptTransfer::decode(&mut src, header)?; - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_BULK_OR_INTERRUPT_TRANSFER"); + ensure_transfer_flag!( + TransferDirection::In, + urb.transfer_flags, + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + ); Self::BulkInterruptTransfer(urb) } - IsochTransfer | IsochTransferUsingChainedMdl => { + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL => { let urb = TsUrbIsochTransfer::decode(&mut src, header)?; - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); Self::IsochTransfer(urb) } - GetDescriptorFromDevice | GetDescriptorFromEndpoint | GetDescriptorFromInterface => { - Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src, header)?) - } - SetDescriptorToDevice | SetDescriptorToEndpoint | SetDescriptorToInterface => { + UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE => { Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src, header)?) } - #[expect(unused_parens)] - (SetFeatureToDevice | SetFeatureToInterface | SetFeatureToEndpoint | SetFeatureToOther) - | (ClearFeatureToDevice | ClearFeatureToInterface | ClearFeatureToEndpoint | ClearFeatureToOther) => { + UrbFunction::URB_FUNCTION_SET_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_OTHER + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_OTHER => { Self::CtlFeatReq(TsUrbControlFeatRequest::decode(&mut src, header)?) } - GetStatusFromDevice | GetStatusFromInterface | GetStatusFromEndpoint | GetStatusFromOther => { + UrbFunction::URB_FUNCTION_GET_STATUS_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_INTERFACE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_OTHER => { Self::CtlGetStatus(TsUrbControlGetStatusRequest::decode(&mut src, header)?) } - #[expect(unused_parens)] - (VendorDevice | VendorInterface | VendorEndpoint | VendorOther) - | (ClassDevice | ClassInterface | ClassEndpoint | ClassOther) => { + UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER + | UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER => { let urb = TsUrbControlVendorClassRequest::decode(&mut src, header)?; - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST"); + ensure_transfer_flag!( + TransferDirection::In, + urb.transfer_flags, + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + ); Self::VendorClassReq(urb) } - GetConfiguration => Self::CtlGetConfig(TsUrbControlGetConfigRequest::decode(&mut src, header)?), - - GetInterface => Self::CtlGetIface(TsUrbControlGetInterfaceRequest::decode(&mut src, header)?), - - GetMsFeatureDescriptor => Self::OsFeatDescReq(TsUrbOsFeatDescRequest::decode(&mut src, header)?), - }; - - Ok(ts_urb) - } - - pub(crate) fn encode(&self, dst: &mut WriteCursor<'_>, direction: TransferDirection) -> EncodeResult<()> { - use TsUrb::*; - - if matches!(direction, TransferDirection::In) { - if let CtlDescReq(ctl_desc_req) = self { - match ctl_desc_req.header.func { - UrbFunction::SetDescriptorToDevice => { - return Err(ctl_desc_func_err!(IN, "URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE")); - } - UrbFunction::SetDescriptorToEndpoint => { - return Err(ctl_desc_func_err!(IN, "URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT")); - } - UrbFunction::SetDescriptorToInterface => { - return Err(ctl_desc_func_err!(IN, "URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE")); - } - _ => (), - } + UrbFunction::URB_FUNCTION_GET_CONFIGURATION => { + Self::CtlGetConfig(TsUrbControlGetConfigRequest::decode(&mut src, header)?) } - } - // Weed out all URBs that are only used with TRANSFER_IN_REQUEST - if matches!(direction, TransferDirection::Out) { - macro_rules! invalid_tsurb_err { - ($reflected_ts_urb:expr) => {{ - invalid_field_err!( - "TRANSFER_OUT_REQUEST::TsUrb", - concat!("is ", $reflected_ts_urb, " (only used with TRANSFER_IN_REQUEST)") - ) - }}; + UrbFunction::URB_FUNCTION_GET_INTERFACE => { + Self::CtlGetIface(TsUrbControlGetInterfaceRequest::decode(&mut src, header)?) } - match self { - SelectConfig(_) => return Err(invalid_tsurb_err!("TS_URB_SELECT_CONFIGURATION")), - SelectIface(_) => return Err(invalid_tsurb_err!("TS_URB_SELECT_CONFIGURATION")), - PipeReq(_) => return Err(invalid_tsurb_err!("TS_URB_PIPE_REQUEST")), - GetCurFrameNum(_) => return Err(invalid_tsurb_err!("TS_URB_GET_CURRENT_FRAME_NUMBER")), - CtlDescReq(ctl_desc_req) => match ctl_desc_req.header.func { - UrbFunction::GetDescriptorFromDevice => { - return Err(ctl_desc_func_err!(OUT, "URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE")); - } - UrbFunction::GetDescriptorFromEndpoint => { - return Err(ctl_desc_func_err!(OUT, "URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT")); - } - UrbFunction::GetDescriptorFromInterface => { - return Err(ctl_desc_func_err!(OUT, "URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE")); - } - _ => (), - }, - CtlFeatReq(_) => return Err(invalid_tsurb_err!("TS_URB_CONTROL_FEATURE_REQUEST")), - CtlGetStatus(_) => return Err(invalid_tsurb_err!("TS_URB_CONTROL_GET_STATUS_REQUEST")), - CtlGetConfig(_) => return Err(invalid_tsurb_err!("TS_URB_CONTROL_GET_CONFIGURATION_REQUEST")), - CtlGetIface(_) => return Err(invalid_tsurb_err!("TS_URB_CONTROL_GET_INTERFACE_REQUEST")), - OsFeatDescReq(_) => return Err(invalid_tsurb_err!("TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST")), - _ => (), + UrbFunction::URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR => { + Self::OsFeatDescReq(TsUrbOsFeatDescRequest::decode(&mut src, header)?) } - } + func => return Err(unsupported_value_err!("URB Function", format!("{}", u16::from(func)))), + }; - macro_rules! ensure_no_ack { - ($direction:expr, $no_ack:expr, $ts_urb_name:expr) => {{ - if matches!($direction, TransferDirection::In) && $no_ack { - return Err(invalid_field_err!( - concat!( - "TRANSFER_IN_REQUEST::TsUrb: ", - $ts_urb_name, - "::TS_URB_HEADER::NoAck" - ), - "is non-zero: NoAck MUST be set to zero for TRANSFER_IN_REQUEST" - )); - } - }}; - } + Ok(ts_urb) + } +} +impl Encode for TsUrbIn { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + use TsUrbIn::*; match self { SelectConfig(urb) => urb.encode(dst), SelectIface(urb) => urb.encode(dst), PipeReq(urb) => urb.encode(dst), GetCurFrameNum(urb) => urb.encode(dst), CtlTransfer(urb) => { - ensure_no_ack!(direction, urb.header.no_ack, "TS_URB_CONTROL_TRANSFER"); - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); urb.encode(dst) } BulkInterruptTransfer(urb) => { - ensure_no_ack!(direction, urb.header.no_ack, "TS_URB_BULK_OR_INTERRUPT_TRANSFER"); - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_BULK_OR_INTERRUPT_TRANSFER"); + ensure_transfer_flag!( + TransferDirection::In, + urb.transfer_flags, + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + ); urb.encode(dst) } IsochTransfer(urb) => { - ensure_no_ack!(direction, urb.header.no_ack, "TS_URB_ISOCH_TRANSFER"); - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); urb.encode(dst) } CtlDescReq(urb) => urb.encode(dst), CtlFeatReq(urb) => urb.encode(dst), CtlGetStatus(urb) => urb.encode(dst), VendorClassReq(urb) => { - ensure_no_ack!(direction, urb.header.no_ack, "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST"); - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST"); + ensure_transfer_flag!( + TransferDirection::In, + urb.transfer_flags, + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + ); urb.encode(dst) } CtlGetConfig(urb) => urb.encode(dst), CtlGetIface(urb) => urb.encode(dst), OsFeatDescReq(urb) => urb.encode(dst), CtlTransferEx(urb) => { - ensure_no_ack!(direction, urb.header.no_ack, "TS_URB_CONTROL_TRANSFER_EX"); - ensure_transfer_flag!(direction, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); urb.encode(dst) } } } - pub fn name(&self) -> &'static str { + fn name(&self) -> &'static str { "TS_URB" } - pub fn size(&self) -> usize { - use TsUrb::*; - + fn size(&self) -> usize { + use TsUrbIn::*; match self { SelectConfig(urb) => urb.size(), SelectIface(urb) => urb.size(), @@ -350,6 +243,134 @@ impl TsUrb { } } +/// Enumeration of all the [\[MS-RDPEUSB\] 2.2.9 TS_URB TRANSFER_OUT_REQUEST Structures][1]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/eed35296-3ca1-4271-bd0a-597138131b47 +#[derive(Debug, PartialEq, Clone)] +pub enum TsUrbOut { + CtlTransfer(TsUrbControlTransfer), + BulkInterruptTransfer(TsUrbBulkOrInterruptTransfer), + IsochTransfer(TsUrbIsochTransfer), + CtlDescReq(TsUrbControlDescRequest), + VendorClassReq(TsUrbControlVendorClassRequest), + CtlTransferEx(TsUrbControlTransferEx), +} + +impl Decode<'_> for TsUrbOut { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = TsUrbHeader::decode(src)?; + + let payload_size = usize::from(header.ts_urb_size) - header.size(); + ensure_size!(in: src, size: payload_size); + let mut src = ReadCursor::new(src.read_slice(payload_size)); + + let ts_urb = match header.func { + UrbFunction::URB_FUNCTION_CONTROL_TRANSFER => { + let urb = TsUrbControlTransfer::decode(&mut src, header)?; + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); + Self::CtlTransfer(urb) + } + UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX => { + let urb = TsUrbControlTransferEx::decode(&mut src, header)?; + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); + Self::CtlTransferEx(urb) + } + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL => { + let urb = TsUrbBulkOrInterruptTransfer::decode(&mut src, header)?; + ensure_transfer_flag!( + TransferDirection::Out, + urb.transfer_flags, + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + ); + Self::BulkInterruptTransfer(urb) + } + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL => { + let urb = TsUrbIsochTransfer::decode(&mut src, header)?; + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); + Self::IsochTransfer(urb) + } + UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE => { + Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src, header)?) + } + UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER + | UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER => { + let urb = TsUrbControlVendorClassRequest::decode(&mut src, header)?; + ensure_transfer_flag!( + TransferDirection::Out, + urb.transfer_flags, + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + ); + Self::VendorClassReq(urb) + } + func => return Err(unsupported_value_err!("URB Function", format!("{}", u16::from(func)))), + }; + + Ok(ts_urb) + } +} + +impl Encode for TsUrbOut { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + use TsUrbOut::*; + match self { + CtlTransfer(urb) => { + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); + urb.encode(dst) + } + BulkInterruptTransfer(urb) => { + ensure_transfer_flag!( + TransferDirection::Out, + urb.transfer_flags, + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + ); + urb.encode(dst) + } + IsochTransfer(urb) => { + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); + urb.encode(dst) + } + CtlDescReq(urb) => urb.encode(dst), + VendorClassReq(urb) => { + ensure_transfer_flag!( + TransferDirection::Out, + urb.transfer_flags, + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + ); + urb.encode(dst) + } + CtlTransferEx(urb) => { + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); + urb.encode(dst) + } + } + } + + fn size(&self) -> usize { + use TsUrbOut::*; + match self { + CtlTransfer(urb) => urb.size(), + BulkInterruptTransfer(urb) => urb.size(), + IsochTransfer(urb) => urb.size(), + CtlDescReq(urb) => urb.size(), + VendorClassReq(urb) => urb.size(), + CtlTransferEx(urb) => urb.size(), + } + } + + fn name(&self) -> &'static str { + "TS_URB" + } +} + #[repr(u8)] #[derive(PartialEq, Clone, Copy)] pub(crate) enum TransferDirection { @@ -357,14 +378,6 @@ pub(crate) enum TransferDirection { In = 0x1, } -macro_rules! encode_ts_urb_size { - ($dst:expr, $size:expr) => { { - let size = u16::try_from($size).map_err(|e| other_err!(source: e))?; - $dst.write_u16(size); - } - }; -} - /// [\[MS-RDPEUSB\] 2.2.9.2 TS_URB_SELECT_CONFIGURATION][1] packet. /// /// This packet represents [`URB_SELECT_CONFIGURATION`][2], and is sent using [`TransferInRequest`] @@ -410,7 +423,7 @@ impl TsUrbSelectConfig { impl Encode for TsUrbSelectConfig { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::SelectConfiguration) { + if !matches!(self.header.func, UrbFunction::URB_FUNCTION_SELECT_CONFIGURATION) { return Err(invalid_field_err!( "TS_URB_SELECT_CONFIGURATION::TS_URB_HEADER::URB_Function", "is not URB_FUNCTION_SELECT_CONFIGURATION" @@ -423,8 +436,7 @@ impl Encode for TsUrbSelectConfig { )); } ensure_size!(in: dst, size: self.size()); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; // ConfigurationDescriptorIsValid dst.write_u8(self.desc.is_some().into()); @@ -456,8 +468,7 @@ impl Encode for TsUrbSelectConfig { } fn size(&self) -> usize { - size_of::(/* TS_URB_HEADER::Size */) - + TsUrbHeader::FIXED_PART_SIZE + TsUrbHeader::FIXED_PART_SIZE + const { size_of::(/* ConfigurationDescriptorIsValid */) + (3 * size_of::()/* Padding */) @@ -503,7 +514,7 @@ impl TsUrbSelectInterface { impl Encode for TsUrbSelectInterface { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::SelectInterface) { + if !matches!(self.header.func, UrbFunction::URB_FUNCTION_SELECT_INTERFACE) { return Err(invalid_field_err!( "TS_URB_SELECT_INTERFACE::TS_URB_HEADER::URB_Function", "is not URB_FUNCTION_SELECT_INTERFACE" @@ -517,8 +528,7 @@ impl Encode for TsUrbSelectInterface { } ensure_size!(in: dst, size: self.size()); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.config_handle); self.usbd_iface.encode(dst) } @@ -528,8 +538,7 @@ impl Encode for TsUrbSelectInterface { } fn size(&self) -> usize { - size_of::(/* TS_URB_HEADER::Size */) - + TsUrbHeader::FIXED_PART_SIZE + TsUrbHeader::FIXED_PART_SIZE + const { size_of::(/* ConfigurationHandle */) } @@ -552,8 +561,7 @@ pub struct TsUrbPipeRequest { } impl TsUrbPipeRequest { - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + size_of::(/* PipeHandle */); + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + size_of::(/* PipeHandle */); pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: const { size_of::(/* PipeHandle */) }); @@ -566,10 +574,13 @@ impl TsUrbPipeRequest { impl Encode for TsUrbPipeRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - use UrbFunction::*; if !matches!( self.header.func, - AbortPipe | SyncResetPipeAndClearStall | SyncResetPipe | SyncClearStall | CloseStaticStreams + UrbFunction::URB_FUNCTION_ABORT_PIPE + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE + | UrbFunction::URB_FUNCTION_SYNC_CLEAR_STALL + | UrbFunction::URB_FUNCTION_CLOSE_STATIC_STREAMS ) { return Err(invalid_field_err!( "TS_URB_PIPE_REQUEST::TS_URB_HEADER::URB_Function", @@ -589,8 +600,7 @@ impl Encode for TsUrbPipeRequest { } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe_handle); Ok(()) @@ -619,7 +629,7 @@ pub struct TsUrbGetCurrFrameNum { } impl TsUrbGetCurrFrameNum { - pub const FIXED_PART_SIZE: usize = size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE; #[inline] pub fn decode(_: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { @@ -629,7 +639,7 @@ impl TsUrbGetCurrFrameNum { impl Encode for TsUrbGetCurrFrameNum { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::GetCurrentFrameNumber) { + if !matches!(self.header.func, UrbFunction::URB_FUNCTION_GET_CURRENT_FRAME_NUMBER) { return Err(invalid_field_err!( "TS_URB_GET_CURRENT_FRAME_NUMBER::TS_URB_HEADER::URB_Function", "is not URB_FUNCTION_GET_CURRENT_FRAME_NUMBER" @@ -641,8 +651,7 @@ impl Encode for TsUrbGetCurrFrameNum { "is non-zero" )); } - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst) + self.header.encode_with_size(dst, self.size()) } fn name(&self) -> &'static str { @@ -675,8 +684,7 @@ impl TsUrbControlTransfer { pub const PAYLOAD_SIZE: usize = size_of::(/* PipeHandle */) + size_of::(/* TransferFlags */) + SetupPacket::FIXED_PART_SIZE; - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); @@ -696,15 +704,14 @@ impl TsUrbControlTransfer { impl Encode for TsUrbControlTransfer { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::ControlTransfer) { + if !matches!(self.header.func, UrbFunction::URB_FUNCTION_CONTROL_TRANSFER) { return Err(invalid_field_err!( "TS_URB_CONTROL_TRANSFER::TS_URB_HEADER::URB_Function", "is not URB_FUNCTION_CONTROL_TRANSFER" )); } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe); dst.write_u32(self.transfer_flags); self.setup_packet.encode(dst) @@ -738,8 +745,7 @@ pub struct TsUrbBulkOrInterruptTransfer { impl TsUrbBulkOrInterruptTransfer { pub const PAYLOAD_SIZE: usize = size_of::(/* PipeHandle */) + size_of::(/* TransferFlags */); - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); @@ -759,7 +765,8 @@ impl Encode for TsUrbBulkOrInterruptTransfer { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { if !matches!( self.header.func, - UrbFunction::BulkOrInterruptTransfer | UrbFunction::BulkOrInterruptTransferUsingChainedMdl + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL ) { return Err(invalid_field_err!( "TS_URB_BULK_OR_INTERRUPT_TRANSFER::TS_URB_HEADER::URB_Function", @@ -767,9 +774,8 @@ impl Encode for TsUrbBulkOrInterruptTransfer { )); } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe_handle); dst.write_u32(self.transfer_flags); @@ -800,10 +806,8 @@ pub struct TsUrbIsochTransfer { pub pipe_handle: PipeHandle, pub transfer_flags: u32, pub start_frame: FrameNumber, - // /// Unused. pub error_count: u32, - // pub iso_packet: Vec, - pub iso_packet_offsets: Vec, + pub iso_packet: Vec, } impl TsUrbIsochTransfer { @@ -815,15 +819,11 @@ impl TsUrbIsochTransfer { let start_frame = src.read_u32(); let number_of_packets = src.read_u32(); let error_count = src.read_u32(); - // src.advance(4); // ErrorCount #[expect(clippy::map_with_unused_argument_over_ranges)] - let iso_packet_offsets = (0..number_of_packets) - .map(|_| { - UsbdIsoPacketDesc::decode(src) - .and_then(|iso| usize::try_from(iso.offset).map_err(|e| other_err!(source: e))) - }) - .collect::, _>>()?; + let iso_packet = (0..number_of_packets) + .map(|_| UsbdIsoPacketDesc::decode(src)) + .collect::, _>>()?; Ok(Self { header, @@ -831,59 +831,36 @@ impl TsUrbIsochTransfer { transfer_flags, start_frame, error_count, - // iso_packet, - iso_packet_offsets, + iso_packet, }) } } impl Encode for TsUrbIsochTransfer { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - use UrbFunction::{IsochTransfer, IsochTransferUsingChainedMdl}; - if !matches!(self.header.func, IsochTransfer | IsochTransferUsingChainedMdl) { + if !matches!( + self.header.func, + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) { return Err(invalid_field_err!( "TS_URB_ISOCH_TRANSFER::TS_URB_HEADER::URB_Function", "is not one of: URB_FUNCTION_ISOCH_TRANSFER, URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL" )); } ensure_size!(in: dst, size: self.size()); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe_handle); dst.write_u32(self.transfer_flags); dst.write_u32(self.start_frame); - // dst.write_u32(self.iso_packet.len().try_into().map_err(|_| { - // invalid_field_err!( - // "TS_URB_ISOCH_TRANSFER::IsoPacket", - // "too many packets: count exceeded field NumberOfPackets (4 bytes)" - // ) - // })?); - dst.write_u32(self.iso_packet_offsets.len().try_into().map_err(|_| { + dst.write_u32(self.iso_packet.len().try_into().map_err(|_| { invalid_field_err!( "TS_URB_ISOCH_TRANSFER::IsoPacket", "too many packets: count exceeded field NumberOfPackets (4 bytes)" ) })?); dst.write_u32(self.error_count); - // self.iso_packet.iter().try_for_each(|packet| packet.encode(dst))?; - self.iso_packet_offsets.iter().try_for_each(|offset| { - u32::try_from(*offset) - .map_err(|e| other_err!(source: e)) - .and_then(|offset| { - UsbdIsoPacketDesc { - offset, - length: 0, - status: 0, - } - .encode(dst) - }) - // u32::try_from(*offset) - // .map(|offset| dst.write_u32(offset)) - // .map_err(|e| other_err!(source: e)) - })?; - - Ok(()) + self.iso_packet.iter().try_for_each(|packet| packet.encode(dst)) } fn name(&self) -> &'static str { @@ -891,8 +868,7 @@ impl Encode for TsUrbIsochTransfer { } fn size(&self) -> usize { - size_of::(/* TS_URB_HEADER::Size */) - + TsUrbHeader::FIXED_PART_SIZE + TsUrbHeader::FIXED_PART_SIZE + const { size_of::() + size_of::(/* TransferFlags */) @@ -900,8 +876,7 @@ impl Encode for TsUrbIsochTransfer { + size_of::(/* NumberOfPackets */) + size_of::(/* ErrorCount */) } - // + self.iso_packet.len() * UsbdIsoPacketDesc::FIXED_PART_SIZE - + self.iso_packet_offsets.len() * UsbdIsoPacketDesc::FIXED_PART_SIZE + + self.iso_packet.len() * UsbdIsoPacketDesc::FIXED_PART_SIZE } } @@ -909,10 +884,10 @@ impl Encode for TsUrbIsochTransfer { /// /// This packet represents [`URB_CONTROL_DESCRIPTOR_REQUEST`][2], and is sent using /// [`TransferInRequest`] if URB Function in header is one of -/// [`UrbFunction::GetDescriptorFromDevice`], [`UrbFunction::GetDescriptorFromEndpoint`] or -/// [`UrbFunction::GetDescriptorFromInterface`]; otherwise sent using [`TransferOutRequest`] if -/// URB Function in header is one of [`UrbFunction::SetDescriptorToDevice`], -/// [`UrbFunction::SetDescriptorToEndpoint`] or [`UrbFunction::SetDescriptorToInterface`]. +/// [`UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE`], [`UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT`] or +/// [`UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE`]; otherwise sent using [`TransferOutRequest`] if +/// URB Function in header is one of [`UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE`], +/// [`UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT`] or [`UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE`]. /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c6096d89-01e6-40e1-b1c7-9327487c5fff /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_descriptor_request @@ -929,8 +904,7 @@ impl TsUrbControlDescRequest { pub const PAYLOAD_SIZE: usize = size_of::(/* Index */) + size_of::(/* DescriptorType */) + size_of::(/* LanguageId */); - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); @@ -950,12 +924,15 @@ impl TsUrbControlDescRequest { impl Encode for TsUrbControlDescRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - use UrbFunction::*; #[expect(unused_parens)] if !matches!( self.header.func, - (GetDescriptorFromDevice | GetDescriptorFromEndpoint | GetDescriptorFromInterface) - | (SetDescriptorToDevice | SetDescriptorToEndpoint | SetDescriptorToInterface) + (UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE) + | (UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE) ) { return Err(invalid_field_err!( "TS_URB_CONTROL_DESCRIPTOR_REQUEST::TS_URB_HEADER::URB_Function", @@ -970,8 +947,7 @@ impl Encode for TsUrbControlDescRequest { } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u8(self.index); dst.write_u8(self.desc_type); dst.write_u16(self.lang_id); @@ -1006,8 +982,7 @@ pub struct TsUrbControlFeatRequest { impl TsUrbControlFeatRequest { pub const PAYLOAD_SIZE: usize = size_of::(/* FeatureSelector */) + size_of::(/* Index */); - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); @@ -1025,13 +1000,17 @@ impl TsUrbControlFeatRequest { impl Encode for TsUrbControlFeatRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - use UrbFunction::*; - #[expect(unused_parens)] if !matches!( self.header.func, - (SetFeatureToDevice | SetFeatureToInterface | SetFeatureToEndpoint | SetFeatureToOther) - | (ClearFeatureToDevice | ClearFeatureToInterface | ClearFeatureToEndpoint | ClearFeatureToOther) + (UrbFunction::URB_FUNCTION_SET_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_OTHER) + | (UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_OTHER) ) { return Err(invalid_field_err!( "TS_URB_CONTROL_FEATURE_REQUEST::TS_URB_HEADER::URB_Function", @@ -1054,8 +1033,7 @@ impl Encode for TsUrbControlFeatRequest { } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u16(self.feat_selector); dst.write_u16(self.index); @@ -1088,8 +1066,7 @@ pub struct TsUrbControlGetStatusRequest { impl TsUrbControlGetStatusRequest { pub const PAYLOAD_SIZE: usize = size_of::(/* Index */) + size_of::(/* Padding */); - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); @@ -1103,11 +1080,12 @@ impl TsUrbControlGetStatusRequest { impl Encode for TsUrbControlGetStatusRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - use UrbFunction::*; - if !matches!( self.header.func, - GetStatusFromDevice | GetStatusFromInterface | GetStatusFromEndpoint | GetStatusFromOther + UrbFunction::URB_FUNCTION_GET_STATUS_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_INTERFACE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_OTHER ) { return Err(invalid_field_err!( "TS_URB_CONTROL_GET_STATUS_REQUEST::TS_URB_HEADER::URB_Function", @@ -1126,8 +1104,7 @@ impl Encode for TsUrbControlGetStatusRequest { } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u16(self.index); write_padding!(dst, 2); @@ -1169,8 +1146,7 @@ impl TsUrbControlVendorClassRequest { + size_of::(/* Index */) + size_of::(/* Padding */); - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); @@ -1194,13 +1170,17 @@ impl TsUrbControlVendorClassRequest { impl Encode for TsUrbControlVendorClassRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - use UrbFunction::*; - #[expect(unused_parens)] if !matches!( self.header.func, - (VendorDevice | VendorInterface | VendorEndpoint | VendorOther) - | (ClassDevice | ClassInterface | ClassEndpoint | ClassOther) + (UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER) + | (UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER) ) { return Err(invalid_field_err!( "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST::TS_URB_HEADER::URB_Function", @@ -1217,8 +1197,7 @@ impl Encode for TsUrbControlVendorClassRequest { } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.transfer_flags); write_padding!(dst, 1); // RequestTypeReservedBits dst.write_u8(self.request); @@ -1251,7 +1230,7 @@ pub struct TsUrbControlGetConfigRequest { } impl TsUrbControlGetConfigRequest { - pub const FIXED_PART_SIZE: usize = size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE; #[inline] pub fn decode(_: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { @@ -1261,7 +1240,7 @@ impl TsUrbControlGetConfigRequest { impl Encode for TsUrbControlGetConfigRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::GetConfiguration) { + if !matches!(self.header.func, UrbFunction::URB_FUNCTION_GET_CONFIGURATION) { return Err(invalid_field_err!( "TS_URB_CONTROL_GET_CONFIGURATION_REQUEST::TS_URB_HEADER::URB_Function", "is not URB_FUNCTION_GET_CONFIGURATION" @@ -1274,8 +1253,7 @@ impl Encode for TsUrbControlGetConfigRequest { )); } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst) + self.header.encode_with_size(dst, self.size()) } fn name(&self) -> &'static str { @@ -1304,8 +1282,7 @@ pub struct TsUrbControlGetInterfaceRequest { impl TsUrbControlGetInterfaceRequest { pub const PAYLOAD_SIZE: usize = size_of::(/* Interface */) + size_of::(/* Padding */); - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); @@ -1318,7 +1295,7 @@ impl TsUrbControlGetInterfaceRequest { impl Encode for TsUrbControlGetInterfaceRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::GetInterface) { + if !matches!(self.header.func, UrbFunction::URB_FUNCTION_GET_INTERFACE) { return Err(invalid_field_err!( "TS_URB_CONTROL_GET_INTERFACE_REQUEST::TS_URB_HEADER::URB_Function", "is not URB_FUNCTION_GET_INTERFACE" @@ -1331,8 +1308,7 @@ impl Encode for TsUrbControlGetInterfaceRequest { )); } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u16(self.interface); write_padding!(dst, 2); @@ -1371,18 +1347,18 @@ impl TsUrbOsFeatDescRequest { + size_of::(/* MS_FeatureDescriptorIndex */) + (3 * size_of::()/* Padding2 */); - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); let recipient = src.read_u8() & 0x1F; let interface_number = src.read_u8(); + // WDK requires MS_PageIndex to be 0; current Windows support is limited to 4 KiB. if src.read_u8(/* MS_PageIndex */) != 0 { return Err(invalid_field_err!( "TRANSFER_IN_REQUEST::TsUrb: TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST::MS_PageIndex", - "should be: 0x0" + "must be: 0x0" )); } let ms_feat_desc_index = src.read_u16(); @@ -1399,7 +1375,7 @@ impl TsUrbOsFeatDescRequest { impl Encode for TsUrbOsFeatDescRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::GetMsFeatureDescriptor) { + if !matches!(self.header.func, UrbFunction::URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR) { return Err(invalid_field_err!( "TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST::TS_URB_HEADER::URB_Function", "is not URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR" @@ -1412,8 +1388,7 @@ impl Encode for TsUrbOsFeatDescRequest { )); } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u8(self.recipient & 0x1F); dst.write_u8(self.interface_number); dst.write_u8(0x0); // MS_PageIndex @@ -1456,8 +1431,7 @@ impl TsUrbControlTransferEx { + size_of::(/* Timeout */) + SetupPacket::FIXED_PART_SIZE; - pub const FIXED_PART_SIZE: usize = - size_of::(/* TS_URB_HEADER::Size */) + TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { ensure_size!(in: src, size: Self::PAYLOAD_SIZE); @@ -1479,15 +1453,14 @@ impl TsUrbControlTransferEx { impl Encode for TsUrbControlTransferEx { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::ControlTransferEx) { + if !matches!(self.header.func, UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX) { return Err(invalid_field_err!( "TS_URB_CONTROL_TRANSFER_EX::TS_URB_HEADER::URB_Function", "is not URB_FUNCTION_CONTROL_TRANSFER_EX" )); } ensure_fixed_part_size!(in: dst); - encode_ts_urb_size!(dst, self.size()); - self.header.encode(dst)?; + self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe); dst.write_u32(self.transfer_flags); dst.write_u32(self.timeout); diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs index ef9dca416f..54431578f4 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs @@ -1,11 +1,11 @@ -//! Contains valid URB Functions, the common header [`TsUrbHeader`] for all [`TsUrb`] structures, -//! and utility data types. +//! Contains valid URB Functions, the common header [`TsUrbHeader`] for all [`TsUrbIn`] and +//! [`TsUrbOut`] structures, and utility data types. use alloc::vec::Vec; use ironrdp_core::{ Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, - invalid_field_err, other_err, read_padding, unsupported_value_err, write_padding, + invalid_field_err, other_err, read_padding, write_padding, }; use crate::pdu::utils::RequestIdTransferInOut; @@ -13,10 +13,10 @@ use crate::pdu::utils::RequestIdTransferInOut; use crate::pdu::{ header::SharedMsgHeader, usb_dev::ts_urb::{ - TsUrb, TsUrbBulkOrInterruptTransfer, TsUrbControlDescRequest, TsUrbControlFeatRequest, - TsUrbControlGetConfigRequest, TsUrbControlGetInterfaceRequest, TsUrbControlGetStatusRequest, - TsUrbControlTransfer, TsUrbControlTransferEx, TsUrbControlVendorClassRequest, TsUrbGetCurrFrameNum, - TsUrbIsochTransfer, TsUrbOsFeatDescRequest, TsUrbPipeRequest, TsUrbSelectConfig, TsUrbSelectInterface, + TsUrbBulkOrInterruptTransfer, TsUrbControlDescRequest, TsUrbControlFeatRequest, TsUrbControlGetConfigRequest, + TsUrbControlGetInterfaceRequest, TsUrbControlGetStatusRequest, TsUrbControlTransfer, TsUrbControlTransferEx, + TsUrbControlVendorClassRequest, TsUrbGetCurrFrameNum, TsUrbIn, TsUrbIsochTransfer, TsUrbOsFeatDescRequest, + TsUrbOut, TsUrbPipeRequest, TsUrbSelectConfig, TsUrbSelectInterface, }, }; @@ -33,309 +33,274 @@ use crate::pdu::{ // like it did not receive any of the MDL variants? Cause the client receives the data buffer over // the network, so MDL's don't really make a point. [EDIT] Same behavior for MDL and non-MDL // variants. -#[repr(u16)] -#[non_exhaustive] -#[derive(Debug, PartialEq, Clone, Copy)] -pub enum UrbFunction { +#[repr(transparent)] +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct UrbFunction(u16); + +impl UrbFunction { /// Represents [`URB_FUNCTION_SELECT_CONFIGURATION`][1]. Used with [`TsUrbSelectConfig`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_select_configuration - #[doc(alias = "URB_FUNCTION_SELECT_CONFIGURATION")] - SelectConfiguration = 0, + pub const URB_FUNCTION_SELECT_CONFIGURATION: Self = Self(0); /// Represents [`URB_FUNCTION_SELECT_INTERFACE`][1]. Used with [`TsUrbSelectInterface`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_select_interface - #[doc(alias = "URB_FUNCTION_SELECT_INTERFACE")] - SelectInterface = 1, + pub const URB_FUNCTION_SELECT_INTERFACE: Self = Self(1); /// Represents [`URB_FUNCTION_ABORT_PIPE`][1]. Used with [`TsUrbPipeRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_abort_pipe - #[doc(alias = "URB_FUNCTION_ABORT_PIPE")] - AbortPipe = 2, + pub const URB_FUNCTION_ABORT_PIPE: Self = Self(2); /// Represents [`URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL`][1]. Used with [`TsUrbPipeRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_sync_reset_pipe_and_clear_stall - #[doc(alias = "URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL")] - SyncResetPipeAndClearStall = 30, + pub const URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL: Self = Self(30); /// Represents [`URB_FUNCTION_SYNC_RESET_PIPE`][1]. Used with [`TsUrbPipeRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_sync_reset_pipe - #[doc(alias = "URB_FUNCTION_SYNC_RESET_PIPE")] - SyncResetPipe = 48, + pub const URB_FUNCTION_SYNC_RESET_PIPE: Self = Self(48); /// Represents [`URB_FUNCTION_SYNC_CLEAR_STALL`][1]. Used with [`TsUrbPipeRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_sync_clear_stall - #[doc(alias = "URB_FUNCTION_SYNC_CLEAR_STALL")] - SyncClearStall = 49, + pub const URB_FUNCTION_SYNC_CLEAR_STALL: Self = Self(49); /// Represents [`URB_FUNCTION_CLOSE_STATIC_STREAMS`][1]. Used with [`TsUrbPipeRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_close_static_streams - #[doc(alias = "URB_FUNCTION_CLOSE_STATIC_STREAMS")] - CloseStaticStreams = 54, + pub const URB_FUNCTION_CLOSE_STATIC_STREAMS: Self = Self(54); /// Represents [`URB_FUNCTION_GET_CURRENT_FRAME_NUMBER`][1]. Used with [`TsUrbGetCurrFrameNum`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_current_frame_number - #[doc(alias = "URB_FUNCTION_GET_CURRENT_FRAME_NUMBER")] - GetCurrentFrameNumber = 7, + pub const URB_FUNCTION_GET_CURRENT_FRAME_NUMBER: Self = Self(7); /// Represents [`URB_FUNCTION_CONTROL_TRANSFER`][1]. Used with [`TsUrbControlTransfer`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_control_transfer - #[doc(alias = "URB_FUNCTION_CONTROL_TRANSFER")] - ControlTransfer = 8, + pub const URB_FUNCTION_CONTROL_TRANSFER: Self = Self(8); /// Represents [`URB_FUNCTION_CONTROL_TRANSFER_EX`][1]. Used with [`TsUrbControlTransferEx`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_control_transfer_ex - #[doc(alias = "URB_FUNCTION_CONTROL_TRANSFER_EX")] - ControlTransferEx = 50, + pub const URB_FUNCTION_CONTROL_TRANSFER_EX: Self = Self(50); /// Represents [`URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER`][1]. Used with /// [`TsUrbBulkOrInterruptTransfer`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_bulk_or_interrupt_transfer - #[doc(alias = "URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER")] - BulkOrInterruptTransfer = 9, + pub const URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER: Self = Self(9); /// Represents [`URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL`][1]. Used with /// [`TsUrbBulkOrInterruptTransfer`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_bulk_or_interrupt_transfer_using_chained_mdl - #[doc(alias = "URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL")] - BulkOrInterruptTransferUsingChainedMdl = 55, + pub const URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL: Self = Self(55); /// Represents [`URB_FUNCTION_ISOCH_TRANSFER`][1]. Used with [`TsUrbIsochTransfer`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_isoch_transfer - #[doc(alias = "URB_FUNCTION_ISOCH_TRANSFER")] - IsochTransfer = 10, + pub const URB_FUNCTION_ISOCH_TRANSFER: Self = Self(10); /// Represents [`URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL`][1]. Used with /// [`TsUrbIsochTransfer`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_isoch_transfer_using_chained_mdl - #[doc(alias = "URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL")] - IsochTransferUsingChainedMdl = 56, + pub const URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: Self = Self(56); /// Represents [`URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE`][1]. Used with /// [`TsUrbControlDescRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_descriptor_from_device - #[doc(alias = "URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE")] - GetDescriptorFromDevice = 11, + pub const URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE: Self = Self(11); /// Represents [`URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT`][1]. Used with /// [`TsUrbControlDescRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_descriptor_from_endpoint - #[doc(alias = "URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT")] - GetDescriptorFromEndpoint = 36, + pub const URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT: Self = Self(36); /// Represents [`URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE`][1]. Used with /// [`TsUrbControlDescRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_descriptor_from_interface - #[doc(alias = "URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE")] - GetDescriptorFromInterface = 40, + pub const URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE: Self = Self(40); /// Represents [`URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE`][1]. Used with /// [`TsUrbControlDescRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_descriptor_to_device - #[doc(alias = "URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE")] - SetDescriptorToDevice = 12, + pub const URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE: Self = Self(12); /// Represents [`URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT`][1]. Used with /// [`TsUrbControlDescRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_descriptor_to_endpoint - #[doc(alias = "URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT")] - SetDescriptorToEndpoint = 37, + pub const URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT: Self = Self(37); /// Represents [`URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE`][1]. Used with /// [`TsUrbControlDescRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_descriptor_to_interface - #[doc(alias = "URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE")] - SetDescriptorToInterface = 41, + pub const URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE: Self = Self(41); /// Represents [`URB_FUNCTION_SET_FEATURE_TO_DEVICE`][1]. Used with [`TsUrbControlFeatRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_feature_to_device - #[doc(alias = "URB_FUNCTION_SET_FEATURE_TO_DEVICE")] - SetFeatureToDevice = 13, + pub const URB_FUNCTION_SET_FEATURE_TO_DEVICE: Self = Self(13); /// Represents [`URB_FUNCTION_SET_FEATURE_TO_INTERFACE`][1]. Used with /// [`TsUrbControlFeatRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_feature_to_interface - #[doc(alias = "URB_FUNCTION_SET_FEATURE_TO_INTERFACE")] - SetFeatureToInterface = 14, + pub const URB_FUNCTION_SET_FEATURE_TO_INTERFACE: Self = Self(14); /// Represents [`URB_FUNCTION_SET_FEATURE_TO_ENDPOINT`][1]. Used with /// [`TsUrbControlFeatRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_feature_to_endpoint - #[doc(alias = "URB_FUNCTION_SET_FEATURE_TO_ENDPOINT")] - SetFeatureToEndpoint = 15, + pub const URB_FUNCTION_SET_FEATURE_TO_ENDPOINT: Self = Self(15); /// Represents [`URB_FUNCTION_SET_FEATURE_TO_OTHER`][1]. Used with [`TsUrbControlFeatRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_feature_to_other - #[doc(alias = "URB_FUNCTION_SET_FEATURE_TO_OTHER")] - SetFeatureToOther = 35, + pub const URB_FUNCTION_SET_FEATURE_TO_OTHER: Self = Self(35); /// Represents [`URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE`][1]. Used with /// [`TsUrbControlFeatRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_clear_feature_to_device - #[doc(alias = "URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE")] - ClearFeatureToDevice = 16, + pub const URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE: Self = Self(16); /// Represents [`URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE`][1]. Used with /// [`TsUrbControlFeatRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_clear_feature_to_interface - #[doc(alias = "URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE")] - ClearFeatureToInterface = 17, + pub const URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE: Self = Self(17); /// Represents [`URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT`][1]. Used with /// [`TsUrbControlFeatRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_clear_feature_to_endpoint - #[doc(alias = "URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT")] - ClearFeatureToEndpoint = 18, + pub const URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT: Self = Self(18); /// Represents [`URB_FUNCTION_CLEAR_FEATURE_TO_OTHER`][1]. Used with /// [`TsUrbControlFeatRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_clear_feature_to_other - #[doc(alias = "URB_FUNCTION_CLEAR_FEATURE_TO_OTHER")] - ClearFeatureToOther = 34, + pub const URB_FUNCTION_CLEAR_FEATURE_TO_OTHER: Self = Self(34); /// Represents [`URB_FUNCTION_GET_STATUS_FROM_DEVICE`][1]. Used with /// [`TsUrbControlGetStatusRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_status_from_device - #[doc(alias = "URB_FUNCTION_GET_STATUS_FROM_DEVICE")] - GetStatusFromDevice = 19, + pub const URB_FUNCTION_GET_STATUS_FROM_DEVICE: Self = Self(19); /// Represents [`URB_FUNCTION_GET_STATUS_FROM_INTERFACE`][1]. Used with /// [`TsUrbControlGetStatusRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_status_from_interface - #[doc(alias = "URB_FUNCTION_GET_STATUS_FROM_INTERFACE")] - GetStatusFromInterface = 20, + pub const URB_FUNCTION_GET_STATUS_FROM_INTERFACE: Self = Self(20); /// Represents [`URB_FUNCTION_GET_STATUS_FROM_ENDPOINT`][1]. Used with /// [`TsUrbControlGetStatusRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_status_from_endpoint - #[doc(alias = "URB_FUNCTION_GET_STATUS_FROM_ENDPOINT")] - GetStatusFromEndpoint = 21, + pub const URB_FUNCTION_GET_STATUS_FROM_ENDPOINT: Self = Self(21); /// Represents [`URB_FUNCTION_GET_STATUS_FROM_OTHER`][1]. Used with /// [`TsUrbControlGetStatusRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_status_from_other - #[doc(alias = "URB_FUNCTION_GET_STATUS_FROM_OTHER")] - GetStatusFromOther = 33, + pub const URB_FUNCTION_GET_STATUS_FROM_OTHER: Self = Self(33); /// Represents [`URB_FUNCTION_VENDOR_DEVICE`][1]. Used with [`TsUrbControlVendorClassRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_vendor_device - #[doc(alias = "URB_FUNCTION_VENDOR_DEVICE")] - VendorDevice = 23, + pub const URB_FUNCTION_VENDOR_DEVICE: Self = Self(23); /// Represents [`URB_FUNCTION_VENDOR_INTERFACE`][1]. Used with /// [`TsUrbControlVendorClassRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_vendor_interface - #[doc(alias = "URB_FUNCTION_VENDOR_INTERFACE")] - VendorInterface = 24, + pub const URB_FUNCTION_VENDOR_INTERFACE: Self = Self(24); /// Represents [`URB_FUNCTION_VENDOR_ENDPOINT`][1]. Used with /// [`TsUrbControlVendorClassRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_vendor_endpoint - #[doc(alias = "URB_FUNCTION_VENDOR_ENDPOINT")] - VendorEndpoint = 25, + pub const URB_FUNCTION_VENDOR_ENDPOINT: Self = Self(25); /// Represents [`URB_FUNCTION_VENDOR_OTHER`][1]. Used with [`TsUrbControlVendorClassRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_vendor_other - #[doc(alias = "URB_FUNCTION_VENDOR_OTHER")] - VendorOther = 32, + pub const URB_FUNCTION_VENDOR_OTHER: Self = Self(32); /// Represents [`URB_FUNCTION_CLASS_DEVICE`][1]. Used with [`TsUrbControlVendorClassRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_class_device - #[doc(alias = "URB_FUNCTION_CLASS_DEVICE")] - ClassDevice = 26, + pub const URB_FUNCTION_CLASS_DEVICE: Self = Self(26); /// Represents [`URB_FUNCTION_CLASS_INTERFACE`][1]. Used with /// [`TsUrbControlVendorClassRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_class_interface - #[doc(alias = "URB_FUNCTION_CLASS_INTERFACE")] - ClassInterface = 27, + pub const URB_FUNCTION_CLASS_INTERFACE: Self = Self(27); /// Represents [`URB_FUNCTION_CLASS_ENDPOINT`][1]. Used with [`TsUrbControlVendorClassRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_class_endpoint - #[doc(alias = "URB_FUNCTION_CLASS_ENDPOINT")] - ClassEndpoint = 28, + pub const URB_FUNCTION_CLASS_ENDPOINT: Self = Self(28); /// Represents [`URB_FUNCTION_CLASS_OTHER`][1]. Used with [`TsUrbControlVendorClassRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_class_other - #[doc(alias = "URB_FUNCTION_CLASS_OTHER")] - ClassOther = 31, + pub const URB_FUNCTION_CLASS_OTHER: Self = Self(31); /// Represents [`URB_FUNCTION_GET_CONFIGURATION`][1]. Used with /// [`TsUrbControlGetConfigRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_configuration - #[doc(alias = "URB_FUNCTION_GET_CONFIGURATION")] - GetConfiguration = 38, + pub const URB_FUNCTION_GET_CONFIGURATION: Self = Self(38); /// Represents [`URB_FUNCTION_GET_INTERFACE`][1]. Used with [`TsUrbControlGetInterfaceRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_interface - #[doc(alias = "URB_FUNCTION_GET_INTERFACE")] - GetInterface = 39, + pub const URB_FUNCTION_GET_INTERFACE: Self = Self(39); /// Represents [`URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR`][1]. Used with /// [`TsUrbOsFeatDescRequest`]. /// /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_ms_feature_descriptor - #[doc(alias = "URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR")] - GetMsFeatureDescriptor = 42, + pub const URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR: Self = Self(42); +} + +impl From for UrbFunction { + fn from(value: u16) -> Self { + Self(value) + } } impl From for u16 { - #[expect(clippy::as_conversions)] fn from(value: UrbFunction) -> Self { - value as Self + value.0 } } /// [\[MS-RDPEUSB\] 2.2.9.1.1 TS_URB_HEADER][1]. /// -/// Common header for all of the [`TsUrb`] variants. Analogous to how [`SharedMsgHeader`] is for -/// all the "top-level" packets defined in the spec. +/// Common header for all of the [`TsUrbIn`] and [`TsUrbOut`] variants. Analogous to how +/// [`SharedMsgHeader`] is for all the "top-level" packets defined in the spec. /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/578da9ca-3116-4608-9737-1bf3df4de3d1 #[doc(alias = "TS_URB_HEADER")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbHeader { + /// The size in bytes of the TS_URB structure. + pub ts_urb_size: u16, /// Indicates what function to perform (see [`UrbFunction`]). pub func: UrbFunction, // pub(crate) urb_function: u16, @@ -372,16 +337,29 @@ pub struct TsUrbHeader { impl TsUrbHeader { pub const FIXED_PART_SIZE: usize = - /* size_of::(/* Size */) + */ /* SHOULD BE managed by the outer TS_URB */ - size_of::(/* URB Function */) + size_of::(/* RequestId, NoAck */); + 2 /* Size */ + 2 /* URB Function */ + 4 /* RequestId, NoAck */; + + pub(super) fn encode_with_size(&self, dst: &mut WriteCursor<'_>, ts_urb_size: usize) -> EncodeResult<()> { + let ts_urb_size = ts_urb_size + .try_into() + .map_err(|_| invalid_field_err!("TS_URB_HEADER::Size", "too large: exceeded 2-byte size field"))?; + + Self { + ts_urb_size, + func: self.func, + req_id: self.req_id, + no_ack: self.no_ack, + } + .encode(dst) + } } impl Encode for TsUrbHeader { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - #[expect(clippy::as_conversions)] - dst.write_u16(self.func as u16); + dst.write_u16(self.ts_urb_size); + dst.write_u16(self.func.into()); let no_ack = u32::from(self.no_ack) << 31; let last32 = u32::from(self.req_id) | no_ack; @@ -402,59 +380,33 @@ impl Encode for TsUrbHeader { impl Decode<'_> for TsUrbHeader { fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { ensure_fixed_part_size!(in: src); + let size = src.read_u16(); + if usize::from(size) < Self::FIXED_PART_SIZE { + return Err(invalid_field_err!("TS_URB_HEADER::Size", "is smaller than 8")); + } - let func = match src.read_u16() { - 0 => UrbFunction::SelectConfiguration, - 1 => UrbFunction::SelectInterface, - 2 => UrbFunction::AbortPipe, - 7 => UrbFunction::GetCurrentFrameNumber, - 8 => UrbFunction::ControlTransfer, - 9 => UrbFunction::BulkOrInterruptTransfer, - 10 => UrbFunction::IsochTransfer, - 11 => UrbFunction::GetDescriptorFromDevice, - 12 => UrbFunction::SetDescriptorToDevice, - 13 => UrbFunction::SetFeatureToDevice, - 14 => UrbFunction::SetFeatureToInterface, - 15 => UrbFunction::SetFeatureToEndpoint, - 16 => UrbFunction::ClearFeatureToDevice, - 17 => UrbFunction::ClearFeatureToInterface, - 18 => UrbFunction::ClearFeatureToEndpoint, - 19 => UrbFunction::GetStatusFromDevice, - 20 => UrbFunction::GetStatusFromInterface, - 21 => UrbFunction::GetStatusFromEndpoint, - 23 => UrbFunction::VendorDevice, - 24 => UrbFunction::VendorInterface, - 25 => UrbFunction::VendorEndpoint, - 26 => UrbFunction::ClassDevice, - 27 => UrbFunction::ClassInterface, - 28 => UrbFunction::ClassEndpoint, - 30 => UrbFunction::SyncResetPipeAndClearStall, - 31 => UrbFunction::ClassOther, - 32 => UrbFunction::VendorOther, - 33 => UrbFunction::GetStatusFromOther, - 34 => UrbFunction::ClearFeatureToOther, - 35 => UrbFunction::SetFeatureToOther, - 36 => UrbFunction::GetDescriptorFromEndpoint, - 37 => UrbFunction::SetDescriptorToEndpoint, - 38 => UrbFunction::GetConfiguration, - 39 => UrbFunction::GetInterface, - 40 => UrbFunction::GetDescriptorFromInterface, - 41 => UrbFunction::SetDescriptorToInterface, - 42 => UrbFunction::GetMsFeatureDescriptor, - 48 => UrbFunction::SyncResetPipe, - 49 => UrbFunction::SyncClearStall, - 50 => UrbFunction::ControlTransferEx, - 54 => UrbFunction::CloseStaticStreams, - 55 => UrbFunction::BulkOrInterruptTransferUsingChainedMdl, - 56 => UrbFunction::IsochTransferUsingChainedMdl, - value => return Err(unsupported_value_err!("URB Function", alloc::format!("{value}"))), - }; - // let urb_function = src.read_u16(); + let func = UrbFunction::from(src.read_u16()); let last32 = src.read_u32(); let req_id = RequestIdTransferInOut::try_from(last32 & 0x7F_FF_FF_FF).expect("value clamped"); let no_ack = (last32 >> 31) != 0; + if no_ack + && !matches!( + func, + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + { + return Err(invalid_field_err!( + "TS_URB_HEADER::NoAck", + "this bit can only be set when URB Function is an isochronous transfer" + )); + } - Ok(Self { func, req_id, no_ack }) + Ok(Self { + ts_urb_size: size, + func, + req_id, + no_ack, + }) } } @@ -604,7 +556,9 @@ impl Decode<'_> for TsUsbdInterfaceInfo { )); }; - let mut src = ReadCursor::new(src.read_slice(usize::from(length) - 2)); + let remaining_length = usize::from(length) - 2 /* Length */; + ensure_size!(in: src, size: remaining_length); + let mut src = ReadCursor::new(src.read_slice(remaining_length)); let number_of_pipes_expected = src.read_u16(); let interface_number = src.read_u8(); From 8fe59c19e253aa106a0a94c72dd0f962b881794c Mon Sep 17 00:00:00 2001 From: devolutionsbot <31221910+devolutionsbot@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:22:15 -0400 Subject: [PATCH 275/325] chore(release): prepare for publishing (#1340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Benoît CORTIER Co-authored-by: Benoît Cortier <3809077+CBenoit@users.noreply.github.com> --- Cargo.lock | 224 ++++++++++----------- crates/ironrdp-ainput/CHANGELOG.md | 8 + crates/ironrdp-ainput/Cargo.toml | 4 +- crates/ironrdp-client/Cargo.toml | 2 +- crates/ironrdp-displaycontrol/CHANGELOG.md | 8 + crates/ironrdp-displaycontrol/Cargo.toml | 4 +- crates/ironrdp-dvc-com-plugin/CHANGELOG.md | 4 + crates/ironrdp-dvc-com-plugin/Cargo.toml | 4 +- crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md | 4 + crates/ironrdp-dvc-pipe-proxy/Cargo.toml | 4 +- crates/ironrdp-dvc/CHANGELOG.md | 10 + crates/ironrdp-dvc/Cargo.toml | 2 +- crates/ironrdp-echo/CHANGELOG.md | 8 + crates/ironrdp-echo/Cargo.toml | 4 +- crates/ironrdp-egfx/CHANGELOG.md | 14 ++ crates/ironrdp-egfx/Cargo.toml | 4 +- crates/ironrdp-graphics/CHANGELOG.md | 10 + crates/ironrdp-graphics/Cargo.toml | 2 +- crates/ironrdp-nscodec/Cargo.toml | 1 - crates/ironrdp-rdcleanpath/CHANGELOG.md | 2 + crates/ironrdp-rdcleanpath/Cargo.toml | 2 +- crates/ironrdp-rdpsnd/CHANGELOG.md | 10 + crates/ironrdp-rdpsnd/Cargo.toml | 2 +- crates/ironrdp-server/CHANGELOG.md | 20 ++ crates/ironrdp-server/Cargo.toml | 12 +- crates/ironrdp-session/CHANGELOG.md | 14 ++ crates/ironrdp-session/Cargo.toml | 6 +- crates/ironrdp-viewer/Cargo.toml | 2 +- crates/ironrdp/CHANGELOG.md | 8 + crates/ironrdp/Cargo.toml | 12 +- fuzz/Cargo.lock | 34 ++-- 31 files changed, 282 insertions(+), 163 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0172548255..209e533617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,9 +72,9 @@ dependencies = [ [[package]] name = "aes-kw" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40e4645e6ea320665abf87e13821f9a37ab204b34bcb18e34e7d1dcf2366516e" +checksum = "41ac571010bd60765c56085a4f1d412012a9be2663b1a2f2b19b49318653fd0d" dependencies = [ "aes", "const-oid 0.10.2", @@ -118,7 +118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.11.1", + "bitflags 2.12.1", "cfg-if", "libc", ] @@ -140,7 +140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.11.1", + "bitflags 2.12.1", "cc", "jni 0.22.4", "libc", @@ -445,9 +445,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" dependencies = [ "arbitrary", ] @@ -574,7 +574,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "log", "polling", "rustix 0.38.44", @@ -611,9 +611,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -652,9 +652,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -752,9 +752,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "colorchoice" @@ -868,7 +868,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -1011,7 +1011,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "crossterm_winapi", "derive_more", "document-features", @@ -1103,7 +1103,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "cryptoki-sys", "libloading", "log", @@ -1362,7 +1362,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "objc2 0.6.4", ] @@ -1419,7 +1419,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "bytemuck", "drm-ffi", "drm-fourcc", @@ -2117,9 +2117,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2367,7 +2367,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "crossterm", "dyn-clone", "fuzzy-matcher", @@ -2395,7 +2395,7 @@ dependencies = [ [[package]] name = "ironrdp" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "async-trait", @@ -2441,9 +2441,9 @@ dependencies = [ [[package]] name = "ironrdp-ainput" -version = "0.6.0" +version = "0.7.0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "ironrdp-core", "ironrdp-dvc", "num-derive", @@ -2525,7 +2525,7 @@ dependencies = [ name = "ironrdp-cliprdr" version = "0.6.0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -2577,7 +2577,7 @@ dependencies = [ [[package]] name = "ironrdp-displaycontrol" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2588,7 +2588,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2598,7 +2598,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc-com-plugin" -version = "0.1.1" +version = "0.1.2" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2611,7 +2611,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc-pipe-proxy" -version = "0.4.0" +version = "0.4.1" dependencies = [ "async-trait", "ironrdp-core", @@ -2624,7 +2624,7 @@ dependencies = [ [[package]] name = "ironrdp-echo" -version = "0.2.0" +version = "0.3.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2634,11 +2634,11 @@ dependencies = [ [[package]] name = "ironrdp-egfx" -version = "0.1.0" +version = "0.2.0" dependencies = [ "arbitrary", "bit_field", - "bitflags 2.11.1", + "bitflags 2.12.1", "ironrdp-core", "ironrdp-dvc", "ironrdp-graphics", @@ -2679,10 +2679,10 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.8.0" +version = "0.8.1" dependencies = [ "bit_field", - "bitflags 2.11.1", + "bitflags 2.12.1", "bitvec", "bmp", "bytemuck", @@ -2709,7 +2709,7 @@ name = "ironrdp-mstsgu" version = "0.0.1" dependencies = [ "base64", - "bitflags 2.11.1", + "bitflags 2.12.1", "futures-util", "http-body-util", "hyper", @@ -2737,7 +2737,7 @@ version = "0.8.0" dependencies = [ "arbitrary", "bit_field", - "bitflags 2.11.1", + "bitflags 2.12.1", "byteorder", "der-parser", "expect-test", @@ -2767,7 +2767,7 @@ dependencies = [ [[package]] name = "ironrdp-rdcleanpath" -version = "0.2.1" +version = "0.2.2" dependencies = [ "der 0.7.10", ] @@ -2776,7 +2776,7 @@ dependencies = [ name = "ironrdp-rdpdr" version = "0.6.0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "ironrdp-core", "ironrdp-error", "ironrdp-pdu", @@ -2814,9 +2814,9 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.8.0" +version = "0.8.1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -2838,7 +2838,7 @@ dependencies = [ [[package]] name = "ironrdp-server" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "async-trait", @@ -2871,7 +2871,7 @@ dependencies = [ [[package]] name = "ironrdp-session" -version = "0.9.0" +version = "0.10.0" dependencies = [ "ironrdp-bulk", "ironrdp-connector", @@ -2903,7 +2903,7 @@ dependencies = [ name = "ironrdp-svc" version = "0.7.0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "ironrdp-core", "ironrdp-pdu", ] @@ -3251,21 +3251,21 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.8.1", ] [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "pkg-config", @@ -3313,9 +3313,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru-slab" @@ -3403,9 +3403,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -3455,7 +3455,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3485,7 +3485,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "cfg-if", "cfg_aliases", "libc", @@ -3619,7 +3619,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -3635,7 +3635,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "libc", "objc2 0.6.4", "objc2-core-audio", @@ -3660,7 +3660,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3697,7 +3697,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "objc2 0.6.4", ] @@ -3707,7 +3707,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3719,7 +3719,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.6.2", "dispatch2", "libc", @@ -3732,7 +3732,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -3775,7 +3775,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "dispatch", "libc", @@ -3788,7 +3788,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -3801,7 +3801,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3824,7 +3824,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3836,7 +3836,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3849,7 +3849,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3880,7 +3880,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -3912,7 +3912,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3975,7 +3975,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4395,7 +4395,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "crc32fast", "fdeflate", "flate2", @@ -4535,7 +4535,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.11.1", + "bitflags 2.12.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -4793,16 +4793,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", ] [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", ] [[package]] @@ -5041,7 +5041,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5054,7 +5054,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -5079,9 +5079,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5225,7 +5225,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5397,9 +5397,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -5482,7 +5482,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "calloop", "calloop-wayland-source", "cursor-icon", @@ -5512,9 +5512,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5589,7 +5589,7 @@ checksum = "3db83308ba07f6c54141f7e34a167353f81250fe8ccab87e90c323f4390b0fb0" dependencies = [ "async-dnssd", "async-recursion", - "bitflags 2.11.1", + "bitflags 2.12.1", "bytemuck", "byteorder", "cfg-if", @@ -5734,7 +5734,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6054,9 +6054,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime", @@ -6100,7 +6100,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "bytes", "futures-util", "http", @@ -6248,9 +6248,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unarray" @@ -6266,9 +6266,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -6536,7 +6536,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -6562,7 +6562,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -6574,7 +6574,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "cursor-icon", "wayland-backend", ] @@ -6596,7 +6596,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -6608,7 +6608,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6621,7 +6621,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7101,7 +7101,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.11.1", + "bitflags 2.12.1", "block2 0.5.1", "bytemuck", "calloop", @@ -7169,7 +7169,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1210bde4c851460210856b10dbbbad824f9e1e635794f44cf2ce552972521e44" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "crypto-bigint", "flate2", "iso7816", @@ -7250,7 +7250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.12.1", "indexmap", "log", "serde", @@ -7362,7 +7362,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.12.1", "dlib", "log", "once_cell", @@ -7408,9 +7408,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7440,18 +7440,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/crates/ironrdp-ainput/CHANGELOG.md b/crates/ironrdp-ainput/CHANGELOG.md index 4a6d184ebe..2d624448b2 100644 --- a/crates/ironrdp-ainput/CHANGELOG.md +++ b/crates/ironrdp-ainput/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.6.0...ironrdp-ainput-v0.7.0)] - 2026-06-05 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.5.0...ironrdp-ainput-v0.6.0)] - 2026-05-27 ### Bug Fixes diff --git a/crates/ironrdp-ainput/Cargo.toml b/crates/ironrdp-ainput/Cargo.toml index 70cea3357b..a5fec4e787 100644 --- a/crates/ironrdp-ainput/Cargo.toml +++ b/crates/ironrdp-ainput/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-ainput" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "AInput dynamic channel implementation" edition.workspace = true @@ -18,7 +18,7 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public bitflags = "2.11" num-derive.workspace = true # TODO: remove num-traits.workspace = true # TODO: remove diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 14f25786f2..89eb9b97e8 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -27,7 +27,7 @@ qoiz = ["ironrdp/qoiz"] [dependencies] # Protocols -ironrdp = { path = "../ironrdp", version = "0.15", features = [ +ironrdp = { path = "../ironrdp", version = "0.16", features = [ "session", "input", "graphics", diff --git a/crates/ironrdp-displaycontrol/CHANGELOG.md b/crates/ironrdp-displaycontrol/CHANGELOG.md index a501cef804..8915ea2a49 100644 --- a/crates/ironrdp-displaycontrol/CHANGELOG.md +++ b/crates/ironrdp-displaycontrol/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.6.0...ironrdp-displaycontrol-v0.7.0)] - 2026-06-05 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.5.0...ironrdp-displaycontrol-v0.6.0)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-displaycontrol/Cargo.toml b/crates/ironrdp-displaycontrol/Cargo.toml index bd4a000c0c..38c99be566 100644 --- a/crates/ironrdp-displaycontrol/Cargo.toml +++ b/crates/ironrdp-displaycontrol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-displaycontrol" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "Display control dynamic channel extension implementation" edition.workspace = true @@ -18,7 +18,7 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-dvc-com-plugin/CHANGELOG.md b/crates/ironrdp-dvc-com-plugin/CHANGELOG.md index 5068e7226b..0ebbb990f7 100644 --- a/crates/ironrdp-dvc-com-plugin/CHANGELOG.md +++ b/crates/ironrdp-dvc-com-plugin/CHANGELOG.md @@ -6,6 +6,10 @@ 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). +## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-com-plugin-v0.1.1...ironrdp-dvc-com-plugin-v0.1.2)] - 2026-06-05 + + + ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-com-plugin-v0.1.0...ironrdp-dvc-com-plugin-v0.1.1)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-dvc-com-plugin/Cargo.toml b/crates/ironrdp-dvc-com-plugin/Cargo.toml index 103a130fb6..ea783e626c 100644 --- a/crates/ironrdp-dvc-com-plugin/Cargo.toml +++ b/crates/ironrdp-dvc-com-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc-com-plugin" -version = "0.1.1" +version = "0.1.2" readme = "README.md" description = "DVC COM client plugin loader for IronRDP (Windows)" edition.workspace = true @@ -21,7 +21,7 @@ test = false [target.'cfg(windows)'.dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } tracing = { version = "0.1", features = ["log"] } windows = { version = "0.62", features = [ diff --git a/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md index 05a998fde2..d0bc70a23f 100644 --- a/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md +++ b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md @@ -6,6 +6,10 @@ 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). +## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.4.0...ironrdp-dvc-pipe-proxy-v0.4.1)] - 2026-06-05 + + + ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.3.0...ironrdp-dvc-pipe-proxy-v0.4.0)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml index 0d6aefe0dc..5abddda3fc 100644 --- a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml +++ b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc-pipe-proxy" -version = "0.4.0" +version = "0.4.1" readme = "README.md" description = "DVC named pipe proxy for IronRDP" edition.workspace = true @@ -19,7 +19,7 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public (PduResult type) -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public (SvcMessage type) tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-dvc/CHANGELOG.md b/crates/ironrdp-dvc/CHANGELOG.md index 1a8ca413fd..693ca8b344 100644 --- a/crates/ironrdp-dvc/CHANGELOG.md +++ b/crates/ironrdp-dvc/CHANGELOG.md @@ -6,6 +6,16 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.6.0...ironrdp-dvc-v0.7.0)] - 2026-06-05 + +### Bug Fixes + +- [**breaking**] Add channel_id parameter to DvcChannelListener::create ([#1358](https://github.com/Devolutions/IronRDP/issues/1358)) ([f21470c6dc](https://github.com/Devolutions/IronRDP/commit/f21470c6dc20e1b10b4bbf750a406644479a4b35)) + + Updates the dynamic virtual channel (DVC) client listener interface in ironrdp-dvc to pass the channel_id (from the incoming DYNVC_CREATE_REQ) into the listener’s create method, enabling listeners to differentiate/control per-instance behavior based on the negotiated dynamic channel ID. + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.5.0...ironrdp-dvc-v0.6.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-dvc/Cargo.toml b/crates/ironrdp-dvc/Cargo.toml index 1f7b381e8f..13820d05ca 100644 --- a/crates/ironrdp-dvc/Cargo.toml +++ b/crates/ironrdp-dvc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "DRDYNVC static channel implementation and traits to implement dynamic virtual channels" edition.workspace = true diff --git a/crates/ironrdp-echo/CHANGELOG.md b/crates/ironrdp-echo/CHANGELOG.md index 7ab4e64d46..edb991386a 100644 --- a/crates/ironrdp-echo/CHANGELOG.md +++ b/crates/ironrdp-echo/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-echo-v0.2.0...ironrdp-echo-v0.3.0)] - 2026-06-05 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency + + + ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-echo-v0.1.0...ironrdp-echo-v0.2.0)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-echo/Cargo.toml b/crates/ironrdp-echo/Cargo.toml index 6d2a3a610d..e153221790 100644 --- a/crates/ironrdp-echo/Cargo.toml +++ b/crates/ironrdp-echo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-echo" -version = "0.2.0" +version = "0.3.0" readme = "README.md" description = "Virtual channel echo extension implementation" edition.workspace = true @@ -18,7 +18,7 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-egfx/CHANGELOG.md b/crates/ironrdp-egfx/CHANGELOG.md index 82f362a204..388f5edbd7 100644 --- a/crates/ironrdp-egfx/CHANGELOG.md +++ b/crates/ironrdp-egfx/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. 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). +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-egfx-v0.1.0...ironrdp-egfx-v0.2.0)] - 2026-06-05 + +### Features + +- [**breaking**] Surface total_frames_decoded on the frame-ack callback ([#1345](https://github.com/Devolutions/IronRDP/issues/1345)) ([cf51bdd1d5](https://github.com/Devolutions/IronRDP/commit/cf51bdd1d5ba062132039f5ed6d7871e00af6412)) + +- Cascade Arbitrary derives across ironrdp-egfx public PDU types ([#1334](https://github.com/Devolutions/IronRDP/issues/1334)) ([479a13aa49](https://github.com/Devolutions/IronRDP/commit/479a13aa49478e333ccdc4c8fdf03aa4f36d2cac)) + +### Bug Fixes + +- [**breaking**] Make DecodedFrame fields private with getters to enforce size invariant ([#1331](https://github.com/Devolutions/IronRDP/issues/1331)) ([1534d1b40e](https://github.com/Devolutions/IronRDP/commit/1534d1b40e902a404b020fbae8e970a65ca74458)) + + + ## [0.1.0] - 2026-06-01 ### Added diff --git a/crates/ironrdp-egfx/Cargo.toml b/crates/ironrdp-egfx/Cargo.toml index bd7ef2ee48..dce1406133 100644 --- a/crates/ironrdp-egfx/Cargo.toml +++ b/crates/ironrdp-egfx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-egfx" -version = "0.1.0" +version = "0.2.0" readme = "README.md" description = "Graphics pipeline dynamic channel extension implementation" edition.workspace = true @@ -20,7 +20,7 @@ arbitrary = { version = "1", features = ["derive"], optional = true } bit_field = "0.10" bitflags = "2.11" ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public openh264 = { version = "0.9", optional = true, default-features = false } diff --git a/crates/ironrdp-graphics/CHANGELOG.md b/crates/ironrdp-graphics/CHANGELOG.md index 540a43a67c..74df51fbc1 100644 --- a/crates/ironrdp-graphics/CHANGELOG.md +++ b/crates/ironrdp-graphics/CHANGELOG.md @@ -6,6 +6,16 @@ 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). +## [[0.8.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.8.0...ironrdp-graphics-v0.8.1)] - 2026-06-05 + +### Bug Fixes + +- Bound ZGFX compressor hash table size ([#1344](https://github.com/Devolutions/IronRDP/issues/1344)) ([4e11a17617](https://github.com/Devolutions/IronRDP/commit/4e11a1761750bb706f5c3cef370589d0eb63fc45)) + + Bounds the ZGFX compressor's hash table to prevent O(n·table_size) per-frame compaction on incompressible payloads (e.g., already-encoded H.264). Previously, `compact_hash_table` only halved per-prefix position lists without reducing prefix count, so high-entropy input kept the table above the cap and triggered compaction on every literal byte. The fix evicts whole least-recently-seen prefixes down to a low watermark (half the cap), amortizing compaction to O(1) per byte while preserving reachable matches (distance is already capped at `MAX_MATCH_DISTANCE`). + + + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.7.0...ironrdp-graphics-v0.8.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-graphics/Cargo.toml b/crates/ironrdp-graphics/Cargo.toml index a708c384b9..954c9efe0f 100644 --- a/crates/ironrdp-graphics/Cargo.toml +++ b/crates/ironrdp-graphics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-graphics" -version = "0.8.0" +version = "0.8.1" readme = "README.md" description = "RDP image processing primitives" edition.workspace = true diff --git a/crates/ironrdp-nscodec/Cargo.toml b/crates/ironrdp-nscodec/Cargo.toml index c7d1287601..9a3e4f5255 100644 --- a/crates/ironrdp-nscodec/Cargo.toml +++ b/crates/ironrdp-nscodec/Cargo.toml @@ -3,7 +3,6 @@ name = "ironrdp-nscodec" version = "0.1.0" readme = "README.md" description = "NSCodec ([MS-RDPNSC]) implementation for IronRDP" -publish = false # TODO: publish edition.workspace = true license.workspace = true homepage.workspace = true diff --git a/crates/ironrdp-rdcleanpath/CHANGELOG.md b/crates/ironrdp-rdcleanpath/CHANGELOG.md index 071e3893b2..57da670c5a 100644 --- a/crates/ironrdp-rdcleanpath/CHANGELOG.md +++ b/crates/ironrdp-rdcleanpath/CHANGELOG.md @@ -6,6 +6,8 @@ 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). +## [[0.2.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdcleanpath-v0.2.1...ironrdp-rdcleanpath-v0.2.2)] - 2026-06-05 + ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdcleanpath-v0.2.0...ironrdp-rdcleanpath-v0.2.1)] - 2025-10-02 ### Features diff --git a/crates/ironrdp-rdcleanpath/Cargo.toml b/crates/ironrdp-rdcleanpath/Cargo.toml index e32076282f..7c88376bc8 100644 --- a/crates/ironrdp-rdcleanpath/Cargo.toml +++ b/crates/ironrdp-rdcleanpath/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdcleanpath" -version = "0.2.1" +version = "0.2.2" readme = "README.md" description = "RDCleanPath PDU structure used by IronRDP web client and Devolutions Gateway" edition.workspace = true diff --git a/crates/ironrdp-rdpsnd/CHANGELOG.md b/crates/ironrdp-rdpsnd/CHANGELOG.md index 2b3db77634..a60c4d09d9 100644 --- a/crates/ironrdp-rdpsnd/CHANGELOG.md +++ b/crates/ironrdp-rdpsnd/CHANGELOG.md @@ -6,6 +6,16 @@ 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). +## [[0.8.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.8.0...ironrdp-rdpsnd-v0.8.1)] - 2026-06-05 + +### Documentation + +- Document RdpsndServerHandler::start wFormatNo contract ([#1343](https://github.com/Devolutions/IronRDP/issues/1343)) ([7894d9f093](https://github.com/Devolutions/IronRDP/commit/7894d9f093db3c80f7358af8e0d8beb18964ce45)) + + Adds Rustdoc documentation to `RdpsndServerHandler`, focusing on the contract for `start()`’s `Option` return value so implementers correctly compute `wFormatNo` for Wave/Wave2 PDUs. + + + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.7.0...ironrdp-rdpsnd-v0.8.0)] - 2026-05-27 ### Bug Fixes diff --git a/crates/ironrdp-rdpsnd/Cargo.toml b/crates/ironrdp-rdpsnd/Cargo.toml index 6aeaac9d7d..93cabeb7b1 100644 --- a/crates/ironrdp-rdpsnd/Cargo.toml +++ b/crates/ironrdp-rdpsnd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpsnd" -version = "0.8.0" +version = "0.8.1" readme = "README.md" description = "RDPSND static channel for audio output implemented as described in MS-RDPEA" edition.workspace = true diff --git a/crates/ironrdp-server/CHANGELOG.md b/crates/ironrdp-server/CHANGELOG.md index d8a6bc0790..9de46c3d57 100644 --- a/crates/ironrdp-server/CHANGELOG.md +++ b/crates/ironrdp-server/CHANGELOG.md @@ -6,6 +6,26 @@ 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). +## [[0.12.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.11.0...ironrdp-server-v0.12.0)] - 2026-06-05 + +### Features + +- Opt-in support for NSCodec via feature flag ([#1332](https://github.com/Devolutions/IronRDP/issues/1332)) ([54af8f677f](https://github.com/Devolutions/IronRDP/commit/54af8f677fde726e2734f7bb1b451f3099d63532)) + + Adds an opt-in implementation of the legacy RDP NSCodec encoder as a standalone crate, and wires it into `ironrdp-server` behind a feature flag so servers can serve NSCodec-only clients (notably macOS Microsoft Remote Desktop / Windows App) without default-build behavior changes. + +- Add CredentialValidator trait for server-side auth ([#1172](https://github.com/Devolutions/IronRDP/issues/1172)) ([8a3b126396](https://github.com/Devolutions/IronRDP/commit/8a3b12639632f58291442a292a89fc6e22f82985)) + +### Bug Fixes + +- Emit RGB-channel QOI for opaque captures so ironrdp-session can decode ([#1335](https://github.com/Devolutions/IronRDP/issues/1335)) ([8a9ee6268c](https://github.com/Devolutions/IronRDP/commit/8a9ee6268ccdb5704c2bb60bed6d2adf57761427)) + +### Build + +- [**breaking**] Update `ironrdp-displaycontrol`, `ironrdp-dvc`, and `ironrdp-echo` public dependencies + + + ## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.10.0...ironrdp-server-v0.11.0)] - 2026-06-01 ### Features diff --git a/crates/ironrdp-server/Cargo.toml b/crates/ironrdp-server/Cargo.toml index 3cba1a878d..c01b606689 100644 --- a/crates/ironrdp-server/Cargo.toml +++ b/crates/ironrdp-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-server" -version = "0.11.0" +version = "0.12.0" readme = "README.md" description = "Extendable skeleton for implementing custom RDP servers" edition.workspace = true @@ -38,16 +38,16 @@ tokio = { version = "1", features = ["net", "macros", "sync", "rt"] } # public tokio-rustls = "0.26" # public async-trait = "0.1" ironrdp-async = { path = "../ironrdp-async", version = "0.9" } -ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.6" } +ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.7" } ironrdp-core = { path = "../ironrdp-core", version = "0.2" } -ironrdp-egfx = { path = "../ironrdp-egfx", version = "0.1", optional = true } +ironrdp-egfx = { path = "../ironrdp-egfx", version = "0.2", optional = true } ironrdp-nscodec = { path = "../ironrdp-nscodec", version = "0.1", optional = true, features = ["encoder"] } ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6" } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.6" } # public -ironrdp-echo = { path = "../ironrdp-echo", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7" } # public +ironrdp-echo = { path = "../ironrdp-echo", version = "0.3" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.9", features = ["reqwest"] } ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.9" } # public ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public diff --git a/crates/ironrdp-session/CHANGELOG.md b/crates/ironrdp-session/CHANGELOG.md index 5f258689a1..e20654c35c 100644 --- a/crates/ironrdp-session/CHANGELOG.md +++ b/crates/ironrdp-session/CHANGELOG.md @@ -6,6 +6,20 @@ 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). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.9.0...ironrdp-session-v0.10.0)] - 2026-06-05 + +### Bug Fixes + +- Decode RGBA QOI bitmaps instead of dropping the frame ([#1341](https://github.com/Devolutions/IronRDP/issues/1341)) ([ef20ea4e90](https://github.com/Devolutions/IronRDP/commit/ef20ea4e90455d6c6db0d3521f6522d1e960c0bb)) + + Fixes the client-side QOI decode path in ironrdp-session so RGBA-channel QOI frames are decoded and applied to the framebuffer instead of being dropped, improving interoperability with third-party RDP servers and older ironrdp-server builds that emit RGBA QOI. + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency + + + ## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.8.0...ironrdp-session-v0.9.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-session/Cargo.toml b/crates/ironrdp-session/Cargo.toml index 5be827b393..a6f2076e27 100644 --- a/crates/ironrdp-session/Cargo.toml +++ b/crates/ironrdp-session/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-session" -version = "0.9.0" +version = "0.10.0" readme = "README.md" description = "State machines to drive an RDP session" edition.workspace = true @@ -26,11 +26,11 @@ ironrdp-bulk = { path = "../ironrdp-bulk", version = "0.1" } ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public # TODO: at some point, this dependency could be removed (good for compilation speed) ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["std"] } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.6" } +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7" } tracing = { version = "0.1", features = ["log"] } qoicoubeh = { version = "0.5", optional = true } zstd-safe = { version = "7.2", optional = true, features = ["std"] } diff --git a/crates/ironrdp-viewer/Cargo.toml b/crates/ironrdp-viewer/Cargo.toml index bec6730242..20ffbc2583 100644 --- a/crates/ironrdp-viewer/Cargo.toml +++ b/crates/ironrdp-viewer/Cargo.toml @@ -31,7 +31,7 @@ qoi = ["ironrdp-client/qoi"] qoiz = ["ironrdp-client/qoiz"] [dependencies] -ironrdp = { path = "../ironrdp", version = "0.15", features = ["input", "pdu"] } +ironrdp = { path = "../ironrdp", version = "0.16", features = ["input", "pdu"] } ironrdp-client = { path = "../ironrdp-client", version = "0.1", default-features = false } ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6" } ironrdp-cfg = { path = "../ironrdp-cfg" } diff --git a/crates/ironrdp/CHANGELOG.md b/crates/ironrdp/CHANGELOG.md index 8cd1552b31..3186d85fe1 100644 --- a/crates/ironrdp/CHANGELOG.md +++ b/crates/ironrdp/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.16.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.15.0...ironrdp-v0.16.0)] - 2026-06-05 + +### Build + +- [**breaking**] Update `ironrdp-displaycontrol`, `ironrdp-dvc`, `ironrdp-echo`, `ironrdp-server`, and `ironrdp-session` public dependencies + + + ## [[0.15.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.14.0...ironrdp-v0.15.0)] - 2026-05-27 ### Build diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index 86e2d63a33..d557efec77 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp" -version = "0.15.0" +version = "0.16.0" readme = "README.md" description = "A meta crate re-exporting IronRDP crates for convenience" edition.workspace = true @@ -45,16 +45,16 @@ ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", optional = true } # pu ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6", optional = true } # public ironrdp-connector = { path = "../ironrdp-connector", version = "0.9", optional = true } # public ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.9", optional = true } # public -ironrdp-session = { path = "../ironrdp-session", version = "0.9", optional = true } # public +ironrdp-session = { path = "../ironrdp-session", version = "0.10", optional = true } # public ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8", optional = true } # public ironrdp-input = { path = "../ironrdp-input", version = "0.6", optional = true } # public -ironrdp-server = { path = "../ironrdp-server", version = "0.11", optional = true, features = ["helper"] } # public +ironrdp-server = { path = "../ironrdp-server", version = "0.12", optional = true, features = ["helper"] } # public ironrdp-svc = { path = "../ironrdp-svc", version = "0.7", optional = true } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.6", optional = true } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7", optional = true } # public ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6", optional = true } # public ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8", optional = true } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.6", optional = true } # public -ironrdp-echo = { path = "../ironrdp-echo", version = "0.2", optional = true } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7", optional = true } # public +ironrdp-echo = { path = "../ironrdp-echo", version = "0.3", optional = true } # public [dev-dependencies] ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.9" } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 1f3b492544..1780d536f5 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -75,9 +75,9 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "bitvec" @@ -108,9 +108,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -317,7 +317,7 @@ dependencies = [ [[package]] name = "ironrdp-displaycontrol" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -328,7 +328,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -338,7 +338,7 @@ dependencies = [ [[package]] name = "ironrdp-egfx" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bit_field", "bitflags", @@ -381,7 +381,7 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.8.0" +version = "0.8.1" dependencies = [ "bit_field", "bitflags", @@ -429,7 +429,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.8.0" +version = "0.8.1" dependencies = [ "bitflags", "ironrdp-core", @@ -465,9 +465,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", @@ -475,9 +475,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "md-5" @@ -647,9 +647,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" @@ -770,9 +770,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" From 985d353543cf45eacfe0cc57aca86502665a3a44 Mon Sep 17 00:00:00 2001 From: uchouT Date: Tue, 23 Jun 2026 17:31:49 +0800 Subject: [PATCH 276/325] feat(dvc): expose dynamic channel accessors (#1368) Signed-off-by: uchouT --- crates/ironrdp-dvc/src/client.rs | 4 +++ crates/ironrdp-dvc/src/lib.rs | 48 ++++++++++++++++++++++++++++++++ crates/ironrdp-dvc/src/server.rs | 26 ++++++++++++++++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-dvc/src/client.rs b/crates/ironrdp-dvc/src/client.rs index c383307204..22ca79bfb1 100644 --- a/crates/ironrdp-dvc/src/client.rs +++ b/crates/ironrdp-dvc/src/client.rs @@ -161,6 +161,10 @@ impl DrdynvcClient { self.dynamic_channels.get_by_channel_id(channel_id) } + pub fn get_dvc_by_channel_id_mut(&mut self, channel_id: u32) -> Option<&mut DynamicVirtualChannel> { + self.dynamic_channels.get_by_channel_id_mut(channel_id) + } + fn create_capabilities_response(&mut self, server_version: CapsVersion) -> SvcMessage { let caps_response = DrdynvcClientPdu::Capabilities(CapabilitiesResponsePdu::new(server_version)); debug!("Send DVC Capabilities Response PDU: {caps_response:?}"); diff --git a/crates/ironrdp-dvc/src/lib.rs b/crates/ironrdp-dvc/src/lib.rs index 6126653b0e..9fd4527b25 100644 --- a/crates/ironrdp-dvc/src/lib.rs +++ b/crates/ironrdp-dvc/src/lib.rs @@ -163,5 +163,53 @@ impl DynamicVirtualChannel { } } +#[derive(Debug, Clone, Copy)] +pub struct DynamicChannelRef<'a, T> { + channel_id: DynamicChannelId, + processor: &'a T, +} + +impl DynamicChannelRef<'_, T> { + pub fn channel_id(&self) -> DynamicChannelId { + self.channel_id + } +} + +impl<'a, T: DvcProcessor> DynamicChannelRef<'a, T> { + fn new(channel_id: u32, processor: &'a T) -> Self { + Self { channel_id, processor } + } + + pub fn processor(&self) -> &'a T { + self.processor + } +} + +#[derive(Debug)] +pub struct DynamicChannelMut<'a, T> { + channel_id: DynamicChannelId, + processor: &'a mut T, +} + +impl DynamicChannelMut<'_, T> { + pub fn channel_id(&self) -> DynamicChannelId { + self.channel_id + } +} + +impl<'a, T: DvcProcessor> DynamicChannelMut<'a, T> { + fn new(channel_id: u32, processor: &'a mut T) -> Self { + Self { channel_id, processor } + } + + pub fn processor(&self) -> &T { + self.processor + } + + pub fn processor_mut(&mut self) -> &mut T { + self.processor + } +} + pub type DynamicChannelName = String; pub type DynamicChannelId = u32; diff --git a/crates/ironrdp-dvc/src/server.rs b/crates/ironrdp-dvc/src/server.rs index 21e905e681..b3d54bba94 100644 --- a/crates/ironrdp-dvc/src/server.rs +++ b/crates/ironrdp-dvc/src/server.rs @@ -14,7 +14,7 @@ use tracing::debug; use crate::pdu::{ CapabilitiesRequestPdu, CapsVersion, ClosePdu, CreateRequestPdu, CreationStatus, DrdynvcClientPdu, DrdynvcServerPdu, }; -use crate::{CompleteData, DvcProcessor, encode_dvc_messages}; +use crate::{CompleteData, DvcProcessor, DynamicChannelMut, DynamicChannelRef, encode_dvc_messages}; pub trait DvcServerProcessor: DvcProcessor {} @@ -186,6 +186,30 @@ impl DrdynvcServer { .ok_or_else(|| invalid_field_err!("DRDYNVC", "", "invalid channel id")) } + pub fn dvc_by_id(&self, id: u32) -> Option> { + let channel = self.dynamic_channels.get(id)?; + if channel.state != ChannelState::Opened { + return None; + } + channel + .processor + .as_any() + .downcast_ref() + .map(|p| DynamicChannelRef::new(id, p)) + } + + pub fn dvc_by_id_mut(&mut self, id: u32) -> Option> { + let channel = self.dynamic_channels.get_mut(id)?; + if channel.state != ChannelState::Opened { + return None; + } + channel + .processor + .as_any_mut() + .downcast_mut() + .map(|p| DynamicChannelMut::new(id, p)) + } + /// Creates a new DVC, returns CreateRequest PDU to send to client. /// /// # Panics From 5d534f10a6f62ac7a860521b4e95c8c47b754612 Mon Sep 17 00:00:00 2001 From: Yuval Marcus Date: Tue, 23 Jun 2026 05:33:10 -0400 Subject: [PATCH 277/325] fix(cliprdr): release outgoing locks before initiating a file copy (#1375) --- crates/ironrdp-cliprdr/src/lib.rs | 51 ++++++++++++- .../tests/clipboard/lock_lifecycle.rs | 74 +++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-cliprdr/src/lib.rs b/crates/ironrdp-cliprdr/src/lib.rs index 2fa0df0095..30fad8c1c0 100644 --- a/crates/ironrdp-cliprdr/src/lib.rs +++ b/crates/ironrdp-cliprdr/src/lib.rs @@ -1027,6 +1027,47 @@ impl Cliprdr { // Backend notification deferred until actual cleanup } + /// Immediately sends `Unlock` PDUs for — and drops — every outgoing clipboard lock. + /// + /// Outgoing locks are created when we download files from the remote: each asks the + /// Shared Clipboard Owner to retain File Stream data so we can keep pulling it even + /// after the clipboard changes ([MS-RDPECLIP] 2.2.4.1). They are normally released + /// lazily by the inactivity sweep in [`Self::drive_timeouts`], so concurrent downloads + /// that outlive a *remote* clipboard change aren't aborted. + /// + /// When the **local** side takes clipboard ownership itself (initiating a file copy), + /// those locks point at data we are replacing. Leaving them held while we advertise a + /// fresh `FormatList` makes the server track a lock for a download that will never + /// finish; some servers (notably Windows `rdpclip.exe`) react badly to that overlap. + /// Releasing them up front keeps the lock/ownership state consistent. + /// + /// Returns the `Unlock` PDUs to send (these must precede the new `FormatList` on the + /// wire); empty when no locks are held. + fn release_outgoing_locks(&mut self) -> Vec { + if self.outgoing_locks.is_empty() { + return Vec::new(); + } + + let cleared: Vec = self.outgoing_locks.keys().copied().collect(); + self.outgoing_locks.clear(); + self.current_lock_id = None; + + info!( + count = cleared.len(), + "Releasing outgoing locks before taking clipboard ownership" + ); + + let messages = cleared + .iter() + .map(|id| into_cliprdr_message(ClipboardPdu::UnlockData(LockDataId(*id)))) + .collect(); + + let lock_ids: Vec = cleared.iter().map(|id| LockDataId(*id)).collect(); + self.backend.on_outgoing_locks_cleared(&lock_ids); + + messages + } + /// Lazily runs periodic cleanup during normal API activity. /// /// Cleans up expired locks, stale file contents requests, and inactive @@ -1537,7 +1578,15 @@ impl Cliprdr { let format_list = self.build_format_list(&formats).map_err(|e| encode_err!(e))?; let pdu = ClipboardPdu::FormatList(format_list); - Ok(vec![into_cliprdr_message(pdu)].into()) + // Release any outgoing download locks BEFORE advertising our file list. By + // initiating a file copy we take clipboard ownership, so locks placed for + // downloads from the previous owner are now stale; sending a new FormatList while + // they're still held desyncs the server's lock state. + // The Unlock PDUs must precede the FormatList on the wire. + let mut messages = self.release_outgoing_locks(); + messages.push(into_cliprdr_message(pdu)); + + Ok(messages.into()) } } diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs b/crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs index c3d5e8b79f..aecab1cbb6 100644 --- a/crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs +++ b/crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs @@ -562,3 +562,77 @@ fn on_outgoing_locks_expired_callback_invoked() { // Cleared callback should NOT have fired yet (cleanup hasn't run) assert!(cleared_ids.lock().unwrap().is_empty()); } + +// -- Taking clipboard ownership releases stale download locks -------- + +/// When the local side initiates a file copy (an upload), it takes clipboard +/// ownership — so the outgoing locks placed for downloads from the previous owner are +/// now stale. They must be released with `Unlock` PDUs that PRECEDE our `FormatList` on +/// the wire; otherwise the server keeps tracking a lock for a download that will never +/// complete, which desyncs its clipboard state. +#[test] +fn initiate_file_copy_releases_outgoing_download_locks() { + let mut cliprdr = ready_locking_client(); + + // Two remote file lists => two outgoing download locks (e.g. two in-flight downloads). + let lock1 = process_file_format_list(&mut cliprdr); + let lock2 = process_file_format_list(&mut cliprdr); + assert_eq!(cliprdr.__test_outgoing_locks().len(), 2); + + let messages: Vec = cliprdr + .initiate_file_copy(vec![FileDescriptor::new("upload.txt")]) + .unwrap() + .into(); + + // Every outgoing lock is released and the current lock id is cleared. + assert!( + cliprdr.__test_outgoing_locks().is_empty(), + "outgoing locks must be released when taking clipboard ownership" + ); + assert_eq!(cliprdr.__test_current_lock_id(), None); + + // Wire order: an Unlock for each held lock, THEN our FormatList last. + assert_eq!(messages.len(), 3, "expected 2 Unlock PDUs + 1 FormatList"); + + decode_pdu!(messages[0] => _b0, pdu0); + let id0 = match pdu0 { + ClipboardPdu::UnlockData(id) => id.0, + other => panic!("expected UnlockData first, got {other:?}"), + }; + decode_pdu!(messages[1] => _b1, pdu1); + let id1 = match pdu1 { + ClipboardPdu::UnlockData(id) => id.0, + other => panic!("expected UnlockData second, got {other:?}"), + }; + let mut unlocked = [id0, id1]; + unlocked.sort_unstable(); + let mut expected = [lock1, lock2]; + expected.sort_unstable(); + assert_eq!(unlocked, expected, "both download locks must be unlocked"); + + decode_pdu!(messages[2] => _b2, last_pdu); + assert!( + matches!(last_pdu, ClipboardPdu::FormatList(_)), + "FormatList must come after the Unlock PDUs, got {last_pdu:?}" + ); +} + +/// With no outgoing locks held, `initiate_file_copy` sends only the `FormatList` +/// (no spurious `Unlock`). +#[test] +fn initiate_file_copy_without_locks_sends_only_format_list() { + let mut cliprdr = ready_locking_client(); + assert!(cliprdr.__test_outgoing_locks().is_empty()); + + let messages: Vec = cliprdr + .initiate_file_copy(vec![FileDescriptor::new("upload.txt")]) + .unwrap() + .into(); + + assert_eq!(messages.len(), 1, "expected only a FormatList when no locks are held"); + decode_pdu!(messages[0] => _b0, pdu); + assert!( + matches!(pdu, ClipboardPdu::FormatList(_)), + "expected FormatList, got {pdu:?}" + ); +} From dca254dcf38b2877abb478aa15a3ab1e3db62ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 23 Jun 2026 22:37:33 +0900 Subject: [PATCH 278/325] docs(style): define log-level policy for library crates --- STYLE.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/STYLE.md b/STYLE.md index 85ee31d826..ba70c4e3cc 100644 --- a/STYLE.md +++ b/STYLE.md @@ -151,6 +151,34 @@ error!(%err, "Active stage failed"); **Rationale**: consistency. We can rely on this to filter and collect diagnostics. +### Log levels + +Choose a level by how often the event fires and who needs it, keeping in mind that +IronRDP crates are libraries embedded into a final client which owns the default +verbosity. The final consumer should not be flooded at default level by routine +protocol mechanics. + +- `info!`: reserved for **rare lifecycle milestones** a consumer would typically want + at default verbosity (e.g. connection or session lifecycle transitions). It should + be uncommon in a library, and never used for anything that repeats during normal + operation (per copy/paste, per lock/unlock, per frame, etc.). +- `debug!`: **significant one-off events** — nothing that repeats in abundance, and no + "entering function X" tracing. +- `trace!`: everything else, the fine-grained detail you only want when that is all + that is left to understand a problem. + +```rust +// GOOD: a rare lifecycle milestone the consumer wants by default. +info!(%server_addr, "Connection established"); + +// BAD: fires on every clipboard lock/unlock — routine mechanics belong at debug!/trace!. +info!(count = cleared.len(), "Releasing outgoing locks before taking clipboard ownership"); +``` + +**Rationale**: the binary at the top of the stack decides what to surface to the user; +a library that emits `info!` for routine operations takes that choice away and spams +default logs. + [tracing-fields]: https://docs.rs/tracing/latest/tracing/index.html#recording-fields ## Helper functions From c36032f91b27390a2cd34bfb300cfbe099d847a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 23 Jun 2026 22:37:46 +0900 Subject: [PATCH 279/325] fix: lower verbosity of routine logs in library crates Library crates should not emit info! for routine, repeating operations; that floods the default logs of the final consumer, which owns the verbosity decision. Reserve info! for rare connection/session lifecycle milestones, debug! for significant one-off events, and trace! for the fine-grained detail only needed when nothing else explains a problem. --- crates/ironrdp-cliprdr/src/lib.rs | 38 +++++++++---------- crates/ironrdp-dvc-com-plugin/src/channel.rs | 20 +++++----- crates/ironrdp-dvc-com-plugin/src/worker.rs | 10 ++--- .../src/platform/unix.rs | 4 +- crates/ironrdp-dvc-pipe-proxy/src/proxy.rs | 4 +- crates/ironrdp-dvc-pipe-proxy/src/worker.rs | 16 ++++---- crates/ironrdp-rdpsnd-native/src/cpal.rs | 4 +- 7 files changed, 48 insertions(+), 48 deletions(-) diff --git a/crates/ironrdp-cliprdr/src/lib.rs b/crates/ironrdp-cliprdr/src/lib.rs index 30fad8c1c0..116ca3af68 100644 --- a/crates/ironrdp-cliprdr/src/lib.rs +++ b/crates/ironrdp-cliprdr/src/lib.rs @@ -20,7 +20,7 @@ use pdu::{ FileContentsResponse, FileDescriptor, FormatDataRequest, FormatListResponse, LockDataId, OwnedFormatDataResponse, PackedFileList, }; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, error, trace, warn}; #[rustfmt::skip] // do not reorder use crate::pdu::FormatList; @@ -620,11 +620,11 @@ impl Cliprdr { FormatListResponse::Ok => { if !R::is_server() { if self.state == CliprdrState::Initialization { - info!("Clipboard virtual channel initialized"); + debug!("Clipboard virtual channel initialized"); self.state = CliprdrState::Ready; self.backend.on_ready(); } else { - info!("Remote accepted format list"); + trace!("Remote accepted format list"); } } self.backend.on_format_list_response(true); @@ -640,7 +640,7 @@ impl Cliprdr { self.local_drop_effect_format_id = None; if !self.sent_file_contents_requests.is_empty() { - info!( + debug!( count = self.sent_file_contents_requests.len(), "Clearing pending file contents requests due to FormatListResponse::Fail" ); @@ -664,7 +664,7 @@ impl Cliprdr { fn handle_format_list(&mut self, format_list: FormatList<'_>) -> PduResult> { if R::is_server() && self.state == CliprdrState::Initialization { - info!("Clipboard virtual channel initialized"); + debug!("Clipboard virtual channel initialized"); self.state = CliprdrState::Ready; self.backend.on_ready(); } @@ -740,7 +740,7 @@ impl Cliprdr { if let Some(format) = file_list_format { // Store the format ID for later use when user initiates paste self.remote_file_list_format_id = Some(format.id); - info!(format_id = ?format.id, "FileGroupDescriptorW format available in FormatList"); + trace!(format_id = ?format.id, "FileGroupDescriptorW format available in FormatList"); } // [MS-RDPECLIP] 3.1.5.2.2 - Acknowledge the FormatList before any @@ -830,7 +830,7 @@ impl Cliprdr { } else { match self.state { CliprdrState::Ready => { - info!("User initiated copy, sending format list"); + trace!("User initiated copy, sending format list"); pdus.push(ClipboardPdu::FormatList( self.build_format_list(available_formats).map_err(|e| encode_err!(e))?, )); @@ -863,7 +863,7 @@ impl Cliprdr { self.pending_format_data_request = Some(requested_format); if Some(requested_format) == self.remote_file_list_format_id { - info!(format_id = ?requested_format, "User initiated paste for FileGroupDescriptorW"); + trace!(format_id = ?requested_format, "User initiated paste for FileGroupDescriptorW"); } let pdu = ClipboardPdu::FormatDataRequest(FormatDataRequest { @@ -972,7 +972,7 @@ impl Cliprdr { self.outgoing_locks.insert(clip_data_id, lock); self.current_lock_id = Some(clip_data_id); - info!(clip_data_id, "Sent clipboard lock"); + trace!(clip_data_id, "Sent clipboard lock"); let pdu = ClipboardPdu::LockData(LockDataId(clip_data_id)); Some(vec![into_cliprdr_message(pdu)]) @@ -1015,7 +1015,7 @@ impl Cliprdr { self.current_lock_id = None; if !newly_expired.is_empty() { - info!( + debug!( count = newly_expired.len(), inactivity_timeout_secs = self.lock_inactivity_timeout.as_secs(), max_lifetime_secs = self.lock_max_lifetime.as_secs(), @@ -1052,7 +1052,7 @@ impl Cliprdr { self.outgoing_locks.clear(); self.current_lock_id = None; - info!( + debug!( count = cleared.len(), "Releasing outgoing locks before taking clipboard ownership" ); @@ -1159,7 +1159,7 @@ impl Cliprdr { // Log cleanup summary if !expired_ids.is_empty() { - info!( + debug!( count = expired_ids.len(), clip_data_ids = ?expired_ids, "Automatic lock cleanup completed" @@ -1202,7 +1202,7 @@ impl Cliprdr { } if !stale_stream_ids.is_empty() { - info!( + debug!( count = stale_stream_ids.len(), "Stale file contents request cleanup completed" ); @@ -1230,7 +1230,7 @@ impl Cliprdr { } if !stale_lock_ids.is_empty() { - info!(count = stale_lock_ids.len(), "Upload inactivity cleanup completed"); + debug!(count = stale_lock_ids.len(), "Upload inactivity cleanup completed"); } Ok(messages.into()) @@ -1529,7 +1529,7 @@ impl Cliprdr { .collect(); if validated_files.len() < original_count { - info!( + debug!( total = original_count, valid = validated_files.len(), "File list validation completed with warnings" @@ -1632,7 +1632,7 @@ impl SvcProcessor for Cliprdr { } if let Some(ref file_list) = self.local_file_list { - info!(clip_data_id = id.0, "Locking clipboard with file list snapshot"); + debug!(clip_data_id = id.0, "Locking clipboard with file list snapshot"); self.locked_file_lists.insert(id.0, file_list.clone()); self.locked_file_list_activity.insert(id.0, self.backend.now_ms()); } else { @@ -1652,7 +1652,7 @@ impl SvcProcessor for Cliprdr { // Release the file list snapshot associated with this clipDataId. if self.locked_file_lists.remove(&id.0).is_some() { self.locked_file_list_activity.remove(&id.0); - info!( + debug!( clip_data_id = id.0, "Unlocking clipboard and releasing file list snapshot" ); @@ -1681,7 +1681,7 @@ impl SvcProcessor for Cliprdr { if Some(request.format) == self.local_file_list_format_id { if let Some(ref file_list) = self.local_file_list { // Respond with the stored file list - info!( + debug!( format_id = ?request.format, file_count = file_list.files.len(), "Responding to FileGroupDescriptorW request with stored file list" @@ -1752,7 +1752,7 @@ impl SvcProcessor for Cliprdr { } } - info!( + debug!( file_count = file_list.files.len(), "Received FileGroupDescriptorW from remote" ); diff --git a/crates/ironrdp-dvc-com-plugin/src/channel.rs b/crates/ironrdp-dvc-com-plugin/src/channel.rs index bfec29db02..a9feb570bd 100644 --- a/crates/ironrdp-dvc-com-plugin/src/channel.rs +++ b/crates/ironrdp-dvc-com-plugin/src/channel.rs @@ -16,7 +16,7 @@ use ironrdp_core::impl_as_any; use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; use ironrdp_pdu::{PduResult, pdu_other_err}; use ironrdp_svc::SvcMessage; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, trace, warn}; use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; use windows::Win32::System::RemoteDesktop::{IWTSListenerCallback, IWTSPlugin, IWTSVirtualChannelManager}; use windows::core::{HRESULT, PCSTR, PCWSTR}; @@ -62,7 +62,7 @@ impl DvcProcessor for DvcComChannel { } fn start(&mut self, channel_id: u32) -> PduResult> { - info!( + debug!( channel_name = %self.channel_name, channel_id, "DVC COM channel start" @@ -92,7 +92,7 @@ impl DvcProcessor for DvcComChannel { let accepted = accept_rx.recv().unwrap_or(false); if accepted { - info!( + debug!( channel_name = %self.channel_name, channel_id, "COM plugin accepted DVC channel" @@ -164,7 +164,7 @@ pub fn load_dvc_plugin(dll_path: &Path, on_write_dvc_factory: F) -> PduResult where F: Fn() -> OnWriteDvcMessage + Send + Sync + 'static, { - info!(dll = %dll_path.display(), "Loading DVC COM plugin"); + debug!(dll = %dll_path.display(), "Loading DVC COM plugin"); // Channel for sending commands to the COM worker thread let (command_tx, command_rx) = std_mpsc::channel(); @@ -185,7 +185,7 @@ where match initialize_plugin_on_thread(&dll_path_owned) { Ok((plugin, manager, listeners)) => { let channel_names: Vec = listeners.keys().cloned().collect(); - info!( + debug!( channels = ?channel_names, "Plugin initialized, registered {} listener(s)", channel_names.len() @@ -256,7 +256,7 @@ fn initialize_plugin_on_thread( // SAFETY: loading the DLL into this process let hmodule = unsafe { LoadLibraryW(dll_path_pcwstr) }.map_err(|e| format!("LoadLibraryW failed: {e}"))?; - info!(dll = %dll_path.display(), "DLL loaded successfully"); + trace!(dll = %dll_path.display(), "DLL loaded successfully"); // Get the VirtualChannelGetInstance export let proc_name = PCSTR::from_raw(c"VirtualChannelGetInstance".as_ptr().cast::()); @@ -268,7 +268,7 @@ fn initialize_plugin_on_thread( // SAFETY: transmuting the function pointer; we trust the DLL follows the documented API let get_instance: VirtualChannelGetInstanceFn = unsafe { core::mem::transmute(proc_addr) }; - info!("VirtualChannelGetInstance export found"); + trace!("VirtualChannelGetInstance export found"); // Phase 1: query the number of plugin objects let iid = IWTSPlugin::IID; @@ -283,7 +283,7 @@ fn initialize_plugin_on_thread( )); } - info!(count = num_objs, "Plugin reports {} object(s)", num_objs); + trace!(count = num_objs, "Plugin reports {} object(s)", num_objs); if num_objs == 0 { return Err("plugin returned 0 objects".to_owned()); @@ -311,7 +311,7 @@ fn initialize_plugin_on_thread( // SAFETY: the plugin pointer is a valid IWTSPlugin COM interface pointer let plugin: IWTSPlugin = unsafe { IWTSPlugin::from_raw(plugin_ptr) }; - info!("Got IWTSPlugin COM object"); + trace!("Got IWTSPlugin COM object"); // Create shared state for listeners: we keep an Rc clone so we can read the // map after Initialize() without needing an unsafe cast from the COM pointer. @@ -322,7 +322,7 @@ fn initialize_plugin_on_thread( // SAFETY: calling IWTSPlugin::Initialize with our channel manager unsafe { plugin.Initialize(&manager) }.map_err(|e| format!("IWTSPlugin::Initialize failed: {e}"))?; - info!("IWTSPlugin::Initialize succeeded"); + trace!("IWTSPlugin::Initialize succeeded"); // Read the listener map that the plugin populated during Initialize. let listeners: HashMap = listeners_rc.borrow().clone(); diff --git a/crates/ironrdp-dvc-com-plugin/src/worker.rs b/crates/ironrdp-dvc-com-plugin/src/worker.rs index f111bc0a19..b5e0f4d04d 100644 --- a/crates/ironrdp-dvc-com-plugin/src/worker.rs +++ b/crates/ironrdp-dvc-com-plugin/src/worker.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::sync::mpsc as std_mpsc; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, error, trace, warn}; use windows::Win32::System::RemoteDesktop::{ IWTSListenerCallback, IWTSPlugin, IWTSVirtualChannel, IWTSVirtualChannelCallback, IWTSVirtualChannelManager, }; @@ -50,7 +50,7 @@ pub(crate) fn run_com_worker( command_rx: std_mpsc::Receiver, on_write_dvc_rx: std_mpsc::Receiver, ) { - info!("COM worker thread started"); + debug!("COM worker thread started"); let mut active_channels: HashMap = HashMap::new(); @@ -129,7 +129,7 @@ pub(crate) fn run_com_worker( match result { Ok(()) if accept.as_bool() => { if let Some(callback) = channel_callback { - info!(channel_name = %channel_name, channel_id, "Plugin accepted DVC channel"); + debug!(channel_name = %channel_name, channel_id, "Plugin accepted DVC channel"); active_channels.insert( channel_id, ActiveChannel { @@ -187,7 +187,7 @@ pub(crate) fn run_com_worker( } ComCommand::Shutdown => { - info!("Shutting down COM plugin"); + debug!("Shutting down COM plugin"); // Close all active channels for (channel_id, active) in active_channels.drain() { @@ -216,5 +216,5 @@ pub(crate) fn run_com_worker( } } - info!("COM worker thread exiting"); + debug!("COM worker thread exiting"); } diff --git a/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs b/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs index b021004fc3..7c49d07045 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use tokio::fs; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; -use tracing::{info, trace}; +use tracing::{debug, trace}; use crate::error::DvcPipeProxyError; use crate::os_pipe::OsPipe; @@ -19,7 +19,7 @@ impl OsPipe for UnixPipe { Ok(metadata) => { use std::os::unix::fs::FileTypeExt as _; - info!( + debug!( %pipe_name, "DVC pipe already exists, removing stale file." ); diff --git a/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs b/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs index ea68289b9e..075d5c745d 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs @@ -4,7 +4,7 @@ use ironrdp_core::impl_as_any; use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; use ironrdp_pdu::{PduResult, pdu_other_err}; use ironrdp_svc::SvcMessage; -use tracing::{debug, info}; +use tracing::debug; use crate::worker::{OnWriteDvcMessage, WorkerCtx, run_worker}; @@ -49,7 +49,7 @@ impl DvcProcessor for DvcNamedPipeProxy { } fn start(&mut self, channel_id: u32) -> PduResult> { - info!(%self.channel_name, %self.named_pipe_name, "Starting DVC named pipe proxy"); + debug!(%self.channel_name, %self.named_pipe_name, "Starting DVC named pipe proxy"); let on_write_dvc = self .dvc_write_callback diff --git a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs index f88d8063c4..ec21f0e64f 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs @@ -4,7 +4,7 @@ use ironrdp_dvc::encode_dvc_messages; use ironrdp_pdu::PduResult; use ironrdp_svc::{ChannelFlags, SvcMessage}; use tokio::sync::Notify; -use tracing::{error, info}; +use tracing::{debug, error}; use crate::error::DvcPipeProxyError; use crate::message::RawDataDvcMessage; @@ -77,11 +77,11 @@ async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result { - info!(%channel_name, %pipe_name,"DVC proxy worker thread has started."); + debug!(%channel_name, %pipe_name,"DVC proxy worker thread has started."); pipe? } _ = ctx.abort_event.notified() => { - info!(%channel_name, %pipe_name, "DVC proxy worker thread has been aborted."); + debug!(%channel_name, %pipe_name, "DVC proxy worker thread has been aborted."); return Ok(NextWorkerState::Abort); } }; @@ -95,14 +95,14 @@ async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result { - info!(%channel_name, %pipe_name, "Received abort signal for DVC proxy worker thread."); + debug!(%channel_name, %pipe_name, "Received abort signal for DVC proxy worker thread."); return Ok(NextWorkerState::Abort); } read_bytes_result = read_pipe => { let read_bytes = read_bytes_result?; if read_bytes == 0 { - info!(%channel_name, %pipe_name, "DVC proxy pipe returned EOF"); + debug!(%channel_name, %pipe_name, "DVC proxy pipe returned EOF"); // If client unexpectedly closed the connection, we should // still be able to reconnect to same session. @@ -124,7 +124,7 @@ async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result data, None => { - info!(%channel_name, %pipe_name, "DVC mpsc channel returned EOF."); + debug!(%channel_name, %pipe_name, "DVC mpsc channel returned EOF."); // Server DVC has been closed, there is no point in // trying to reconnect. return Ok(NextWorkerState::Abort); @@ -177,7 +177,7 @@ async fn worker(ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { loop { match process_client::

(&mut bridged_ctx).await? { NextWorkerState::Abort => { - info!( + debug!( channel_name = %bridged_ctx.channel_name, pipe_name = %bridged_ctx.pipe_name, "Aborting DVC proxy worker thread." @@ -185,7 +185,7 @@ async fn worker(ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { break; } NextWorkerState::Reconnect => { - info!( + debug!( channel_name = %bridged_ctx.channel_name, pipe_name = %bridged_ctx.pipe_name, "Reconnecting to DVC pipe..." diff --git a/crates/ironrdp-rdpsnd-native/src/cpal.rs b/crates/ironrdp-rdpsnd-native/src/cpal.rs index e47157a20f..d2832d517e 100644 --- a/crates/ironrdp-rdpsnd-native/src/cpal.rs +++ b/crates/ironrdp-rdpsnd-native/src/cpal.rs @@ -10,7 +10,7 @@ use cpal::traits::{DeviceTrait as _, HostTrait as _}; use cpal::{SampleFormat, Stream, StreamConfig}; use ironrdp_rdpsnd::client::RdpsndClientHandler; use ironrdp_rdpsnd::pdu::{AudioFormat, PitchPdu, VolumePdu, WaveFormat}; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, trace, warn}; #[derive(Debug)] pub struct RdpsndBackend { @@ -272,7 +272,7 @@ impl RxBuffer { } let Some(ref last) = self.last else { - info!("Playback rx underrun"); + trace!("Playback rx underrun"); return; }; From 356d06e52afff03e206db4612202f48786164ab1 Mon Sep 17 00:00:00 2001 From: Yuval Marcus Date: Tue, 23 Jun 2026 11:56:39 -0400 Subject: [PATCH 280/325] fix(web): recover file-upload state when a paste is interrupted or never pulled (#1372) --- crates/ironrdp-web/src/clipboard.rs | 26 ++ crates/ironrdp-web/src/session.rs | 8 + .../src/RdpFileTransferProvider.test.ts | 345 +++++++++++++++++- .../src/RdpFileTransferProvider.ts | 269 ++++++++++++-- .../iron-remote-desktop-rdp/src/extensions.ts | 4 + 5 files changed, 620 insertions(+), 32 deletions(-) diff --git a/crates/ironrdp-web/src/clipboard.rs b/crates/ironrdp-web/src/clipboard.rs index 011cc8d2d0..8bd9c62ca8 100644 --- a/crates/ironrdp-web/src/clipboard.rs +++ b/crates/ironrdp-web/src/clipboard.rs @@ -173,6 +173,16 @@ pub(crate) enum WasmClipboardBackendMessage { LocksExpired { clip_data_ids: Vec, }, + /// [MS-RDPECLIP] 2.2.3.2 Remote's response to one of our outbound Format Lists. + /// + /// `ok` is `true` when the remote accepted the advertised formats + /// (`CB_RESPONSE_OK`) and `false` when it rejected them (`CB_RESPONSE_FAIL`). + /// A rejected advertise is silently discarded by the remote, so a file paste + /// cannot proceed; backends use this to detect and recover from a refused + /// paste instead of stalling until a lock timeout. + FormatListResponse { + ok: bool, + }, // JS-initiated file transfer operations /// JS requests file contents from remote (download). @@ -235,6 +245,7 @@ pub(crate) struct JsClipboardCallbacks { pub(crate) on_lock: Option, pub(crate) on_unlock: Option, pub(crate) on_locks_expired: Option, + pub(crate) on_format_list_response: Option, } impl WasmClipboard { @@ -841,6 +852,16 @@ impl WasmClipboard { ); } } + WasmClipboardBackendMessage::FormatListResponse { ok } => { + if let Some(callback) = self.js_callbacks.on_format_list_response.as_ref() { + if let Err(e) = callback.call1(&JsValue::NULL, &JsValue::from_bool(ok)) { + error!(error = ?e, ok, "Failed to call JS format list response callback"); + return Ok(()); + } + } else { + trace!(ok, "Format list response received but no JS callback registered"); + } + } // The following variants are handled directly in the event loop and should never reach here WasmClipboardBackendMessage::FileContentsRequestSend { .. } | WasmClipboardBackendMessage::FileContentsResponseSend { .. } @@ -948,6 +969,10 @@ impl CliprdrBackend for WasmClipboardBackend { self.send_event(WasmClipboardBackendMessage::Unlock { data_id }); } + fn on_format_list_response(&mut self, ok: bool) { + self.send_event(WasmClipboardBackendMessage::FormatListResponse { ok }); + } + fn on_remote_file_list(&mut self, files: &[ironrdp::cliprdr::pdu::FileDescriptor], clip_data_id: Option) { let file_metadata: Vec = files.iter().map(FileMetadata::from_file_descriptor).collect(); @@ -1227,6 +1252,7 @@ mod tests { on_lock: Some(js_sys::Function::new_no_args("")), on_unlock: Some(js_sys::Function::new_no_args("")), on_locks_expired: Some(js_sys::Function::new_no_args("")), + on_format_list_response: Some(js_sys::Function::new_no_args("")), } } diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index 6189013703..ba1c31e5c5 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -79,6 +79,7 @@ struct SessionBuilderInner { lock_callback: Option, unlock_callback: Option, locks_expired_callback: Option, + format_list_response_callback: Option, // Setting printer stream callbacks activates the virtual printer. invalid_print_job_stream_callbacks: bool, @@ -120,6 +121,7 @@ impl Default for SessionBuilderInner { lock_callback: None, unlock_callback: None, locks_expired_callback: None, + format_list_response_callback: None, invalid_print_job_stream_callbacks: false, print_job_stream_callbacks: None, @@ -276,6 +278,9 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { |locks_expired_callback: JsValue| { self.0.borrow_mut().locks_expired_callback = locks_expired_callback.dyn_into::().ok(); }; + |format_list_response_callback: JsValue| { + self.0.borrow_mut().format_list_response_callback = format_list_response_callback.dyn_into::().ok(); + }; |print_job_stream_callbacks: JsValue| { let mut inner = self.0.borrow_mut(); match parse_print_job_stream_callbacks(print_job_stream_callbacks) { @@ -341,6 +346,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { lock_callback, unlock_callback, locks_expired_callback, + format_list_response_callback, invalid_print_job_stream_callbacks, print_job_stream_callbacks, printer_name, @@ -381,6 +387,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { lock_callback = inner.lock_callback.clone(); unlock_callback = inner.unlock_callback.clone(); locks_expired_callback = inner.locks_expired_callback.clone(); + format_list_response_callback = inner.format_list_response_callback.clone(); invalid_print_job_stream_callbacks = inner.invalid_print_job_stream_callbacks; print_job_stream_callbacks = inner.print_job_stream_callbacks.clone(); printer_name = inner.printer_name.clone(); @@ -410,6 +417,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { on_lock: lock_callback, on_unlock: unlock_callback, on_locks_expired: locks_expired_callback, + on_format_list_response: format_list_response_callback, }, ) }); diff --git a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts index e57c6f56fb..0e9469270e 100644 --- a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts +++ b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts @@ -11,6 +11,7 @@ vi.mock('./extensions', () => ({ lockCallback: (cb: unknown) => ({ ident: 'lock_callback', value: cb }), unlockCallback: (cb: unknown) => ({ ident: 'unlock_callback', value: cb }), locksExpiredCallback: (cb: unknown) => ({ ident: 'locks_expired_callback', value: cb }), + formatListResponseCallback: (cb: unknown) => ({ ident: 'format_list_response_callback', value: cb }), requestFileContents: (params: unknown) => ({ ident: 'request_file_contents', value: params }), submitFileContents: (params: unknown) => ({ ident: 'submit_file_contents', value: params }), initiateFileCopy: (files: unknown) => ({ ident: 'initiate_file_copy', value: files }), @@ -256,7 +257,7 @@ describe('RdpFileTransferProvider', () => { }); describe('upload lifecycle callbacks', () => { - it('should call onUploadStarted and onUploadFinished around initiateFileCopy', async () => { + it('suppresses monitoring on advertise and defers the resume past the wire send', async () => { const onUploadStarted = vi.fn(); const onUploadFinished = vi.fn(); @@ -268,21 +269,271 @@ describe('RdpFileTransferProvider', () => { const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; const { completion: uploadPromise } = p.uploadFiles(files); - // Both should fire immediately (monitoring suppression is brief) + // Monitoring is suppressed before the FormatList goes on the wire... expect(onUploadStarted).toHaveBeenCalledTimes(1); - expect(onUploadFinished).toHaveBeenCalledTimes(1); expect(s.invokeExtension).toHaveBeenCalledTimes(1); - - // Upload started should fire before invokeExtension (initiateFileCopy) expect(onUploadStarted.mock.invocationCallOrder[0]).toBeLessThan( s.invokeExtension.mock.invocationCallOrder[0], ); + // ...but the resume is now DEFERRED (held until the paste is pulled or we + // give up), so the 100ms monitor poll cannot clobber the file FormatList. + expect(onUploadFinished).not.toHaveBeenCalled(); - // Clean up + // Dispose resumes monitoring exactly once and rejects the pending upload. p.dispose(); + expect(onUploadFinished).toHaveBeenCalledTimes(1); await expect(uploadPromise).rejects.toThrow('RdpFileTransferProvider disposed'); }); + it('resumes monitoring on the first FileContentsRequest (remote pulled the files)', async () => { + const onUploadFinished = vi.fn(); + const { provider: p } = setupProvider({ onUploadFinished }); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + expect(onUploadFinished).not.toHaveBeenCalled(); + + // Remote requests file size for index 0 -> paste was pulled -> resume. + // @ts-expect-error - exercising the private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + expect(onUploadFinished).toHaveBeenCalledTimes(1); + + p.dispose(); + // Already resumed on the pull, so dispose does not resume again. + expect(onUploadFinished).toHaveBeenCalledTimes(1); + await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('fails the upload and resumes monitoring if the remote never pulls (watchdog)', async () => { + vi.useFakeTimers(); + try { + const onUploadFinished = vi.fn(); + const { provider: p } = setupProvider({ onUploadFinished }); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + const rejected = expect(completion).rejects.toThrow(/did not request the files/i); + + // No FileContentsRequest ever arrives; after the 60s lock window the + // watchdog fires: resume monitoring + fail the upload. + await vi.advanceTimersByTimeAsync(60_000); + await rejected; + + expect(onUploadFinished).toHaveBeenCalledTimes(1); + const err: FileTransferError = errorHandler.mock.calls.at(-1)![0]; + expect(err.direction).toBe('upload'); + + // uploadState was cleared, so a fresh upload starts instead of throwing + // "Upload already in progress" -- the wedge is gone, no reload needed. + const second = p.uploadFiles(files); + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + } finally { + vi.useRealTimers(); + } + }); + + it('does NOT fail a pulled upload that keeps making progress', async () => { + // Regression guard for normal uploads: a slow-but-progressing transfer resets + // the inactivity watchdog on every request, so it must never be killed even + // long past the window. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + let settled = false; + void completion.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + // Remote keeps pulling, each request well inside the 60s window: the + // watchdog keeps resetting and never fires (total elapsed well past 60s). + for (let i = 0; i < 5; i++) { + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + await vi.advanceTimersByTimeAsync(50_000); + } + + expect(errorHandler).not.toHaveBeenCalled(); + expect(settled).toBe(false); + + p.dispose(); + await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); + } finally { + vi.useRealTimers(); + } + }); + + it('fails a pulled-then-idle upload after the inactivity window, releasing the wedge', async () => { + // Once pulling starts, if the remote goes silent (e.g. it grabbed the clipboard + // with a text/image copy that never reaches handleFilesAvailable), the + // inactivity watchdog releases uploadState so later uploads aren't wedged. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + const rejected = expect(completion).rejects.toThrow(/stopped requesting the files/i); + + // Remote pulls once (arms the inactivity watchdog), then goes silent. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + await vi.advanceTimersByTimeAsync(60_000); + await rejected; + + expect(errorHandler).toHaveBeenCalled(); + expect((errorHandler.mock.calls.at(-1)![0] as FileTransferError).direction).toBe('upload'); + + // uploadState released -> a fresh upload doesn't throw "Upload already in progress". + const second = p.uploadFiles(files); + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + } finally { + vi.useRealTimers(); + } + }); + + it('stops the inactivity watchdog once the upload completes (no lingering timer)', async () => { + // The final FileContentsRequest arms the inactivity watchdog; completion must + // disarm it (finishUploadBatch). Otherwise a stray 60s timer lingers after every + // completed upload -- harmless today thanks to the uploadState guard, but it + // should not exist, and a future change could let it fire against fresh state. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + let resolved = false; + void completion.then(() => { + resolved = true; + }); + + // Remote pulls the whole 4-byte file in one RANGE. jsdom schedules the + // FileReader on a timer, so flushing it completes the batch. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + expect(resolved).toBe(true); + + // Completion ran finishUploadBatch, which cleared the inactivity watchdog: + // no upload timer is left pending (the lingering-timer bug this guards). + expect(vi.getTimerCount()).toBe(0); + + // ...and well past the window nothing fails. + await vi.advanceTimersByTimeAsync(60_000); + expect(errorHandler).not.toHaveBeenCalled(); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('does NOT fail a re-paste that goes idle (isRePaste guard)', async () => { + // After an upload completes, the DroppedFile metadata is retained so the remote + // can re-paste. A re-paste rebuilds uploadState with isRePaste=true and carries + // no external promise, so the inactivity watchdog must leave it alone rather than + // emit a bogus upload error / try to reject a promise nobody is awaiting. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + let resolved = false; + void completion.then(() => { + resolved = true; + }); + + // First paste: remote pulls the whole file -> upload completes, metadata retained. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + expect(resolved).toBe(true); + + // Re-paste: no uploadState but retainedFiles present -> the next request + // rebuilds an isRePaste state and re-arms the inactivity watchdog. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 2, index: 0, flags: 1, position: 0, size: 8 }); + + // The window elapses with no further pulls. A fresh upload would be failed + // here; a re-paste must NOT be (no promise to reject, no error to surface). + await vi.advanceTimersByTimeAsync(60_000); + expect(errorHandler).not.toHaveBeenCalled(); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('releases the in-flight upload when the remote replaces the clipboard', async () => { + // A remote FormatList (the remote copied its own files) supersedes our advertise; + // the upload can no longer complete, so uploadState must be released or it wedges + // every later upload. Symmetric to the rejected-advertise recovery. + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + const rejected = expect(completion).rejects.toThrow(/remote clipboard changed/i); + + // Remote copies its own files mid-upload. + // @ts-expect-error - accessing private method for testing + p.handleFilesAvailable([{ name: 'remote.txt', size: 10, lastModified: 0 }] as FileInfo[]); + await rejected; + + expect(errorHandler).toHaveBeenCalled(); + expect((errorHandler.mock.calls.at(-1)![0] as FileTransferError).direction).toBe('upload'); + + // uploadState released -> a fresh upload doesn't throw "Upload already in progress". + const second = p.uploadFiles(files); + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('does NOT fail an in-flight upload on an Unlock (Unlock is not a paste-failure signal)', async () => { + // The peer/cliprdr emit Unlock routinely (snapshot of the FormatList, and the + // 60s timeout), often before any FileContentsRequest. Treating that as failure + // tore down uploads that would still be pulled, so Unlock must be ignored here. + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + + // @ts-expect-error - private callback the WASM layer drives via unlockCallback + p.handleUnlock(0); + + // No error, and the upload state is intact (a second attempt still hits the guard). + expect(errorHandler).not.toHaveBeenCalled(); + expect(() => p.uploadFiles(files)).toThrow('Upload already in progress'); + + p.dispose(); + await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + it('should call onUploadFinished even on initiateFileCopy failure', async () => { const onUploadStarted = vi.fn(); const onUploadFinished = vi.fn(); @@ -300,11 +551,91 @@ describe('RdpFileTransferProvider', () => { await expect(completion).rejects.toThrow('Failed to initiate file upload'); expect(onUploadStarted).toHaveBeenCalledTimes(1); - // onUploadFinished fires in finally block regardless + // The failure path resumes monitoring (resumeUploadMonitoring), so it fires once. expect(onUploadFinished).toHaveBeenCalledTimes(1); }); }); + describe('format list response (paste accept / reject)', () => { + // Pull the registered format_list_response_callback out of the builder + // extensions and invoke it, exercising the real wiring + handler together. + function fireFormatListResponse(p: RdpFileTransferProviderInstance, ok: boolean): void { + const exts = p.getBuilderExtensions() as unknown as Array<{ ident: string; value: (ok: boolean) => void }>; + const ext = exts.find((e) => e.ident === 'format_list_response_callback'); + if (ext === undefined) { + throw new Error('format_list_response_callback was not registered'); + } + ext.value(ok); + } + + it('registers a format_list_response_callback builder extension', () => { + const idents = (provider.getBuilderExtensions() as unknown as Array<{ ident: string }>).map((e) => e.ident); + expect(idents).toContain('format_list_response_callback'); + }); + + it('emits a format-list-response event carrying the ok flag', () => { + const handler = vi.fn(); + provider.on('format-list-response', handler); + + fireFormatListResponse(provider, true); + fireFormatListResponse(provider, false); + + expect(handler).toHaveBeenNthCalledWith(1, true); + expect(handler).toHaveBeenNthCalledWith(2, false); + }); + + it('fails an in-flight upload cleanly when the remote rejects the advertise', async () => { + const { provider: p, session: s } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + + // Remote rejects the file list before requesting any contents. + fireFormatListResponse(p, false); + + await expect(completion).rejects.toThrow(/rejected the file list/i); + const err: FileTransferError = errorHandler.mock.calls.at(-1)![0]; + expect(err.direction).toBe('upload'); + + // uploadState is cleared, so a fresh upload starts instead of throwing + // "Upload already in progress" (the wedge this fixes). Reaching a handle + // (not a throw) plus the initiateFileCopy call proves it restarted. + s.invokeExtension.mockClear(); + const second = p.uploadFiles(files); + expect(s.invokeExtension).toHaveBeenCalledTimes(1); + + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('leaves an accepted advertise (ok=true) untouched', async () => { + const { provider: p } = setupProvider(); + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + + fireFormatListResponse(p, true); + + // Upload is still in progress: a second attempt still hits the guard. + expect(() => p.uploadFiles(files)).toThrow('Upload already in progress'); + + p.dispose(); + await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('surfaces a reject with no upload in progress without erroring', () => { + const errorHandler = vi.fn(); + const eventHandler = vi.fn(); + provider.on('error', errorHandler); + provider.on('format-list-response', eventHandler); + + expect(() => fireFormatListResponse(provider, false)).not.toThrow(); + expect(eventHandler).toHaveBeenCalledWith(false); + expect(errorHandler).not.toHaveBeenCalled(); + }); + }); + describe('sanitizeFileName', () => { it('should return a plain filename as-is', () => { expect(RdpFileTransferProvider.sanitizeFileName('file.txt')).toBe('file.txt'); diff --git a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts index f7876a6b56..e7b48656de 100644 --- a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts +++ b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts @@ -8,6 +8,7 @@ import { lockCallback, unlockCallback, locksExpiredCallback, + formatListResponseCallback, requestFileContents, submitFileContents, initiateFileCopy, @@ -188,6 +189,11 @@ type EventMap = { * listeners can register all transfers eagerly before progress events arrive. */ 'upload-batch-started': [Map, DroppedFile[]]; 'files-available': [FileInfo[]]; + /** Remote's response to one of our outbound Format Lists: `true` = accepted + * (CB_RESPONSE_OK), `false` = rejected (CB_RESPONSE_FAIL). Fires for every + * outbound advertise, so consumers can drive paste from it (e.g. inject the + * paste keystroke on accept, retry on reject). */ + 'format-list-response': [boolean]; error: [FileTransferError]; }; @@ -262,6 +268,24 @@ export class RdpFileTransferProvider { private static readonly MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; /** Timeout for FileReader operations (60 seconds) to prevent stalled uploads */ private static readonly FILE_READER_TIMEOUT_MS = 60 * 1000; + /** + * How long to keep clipboard monitoring suppressed after advertising an upload + * while waiting for the remote to pull the files (first FileContentsRequest), + * before giving up. Matches the Rust cliprdr lock inactivity timeout (60s), so + * the JS side gives up exactly when the protocol lock does. If the remote never + * requests contents (the paste landed in a non-file target, or the advertise was + * clobbered), the watchdog resumes monitoring and fails the upload so its state + * cannot wedge later uploads. + */ + private static readonly PASTE_ACK_TIMEOUT_MS = 60 * 1000; + /** + * Upload inactivity window. After the remote starts pulling, each FileContentsRequest + * resets this; if pulls then stop for this long -- e.g. the remote grabbed the + * clipboard with a text/image copy that never reaches handleFilesAvailable -- the + * upload is failed so `uploadState` is released and later uploads aren't wedged. A + * slow-but-progressing transfer keeps resetting it, so it is never killed. + */ + private static readonly UPLOAD_INACTIVITY_TIMEOUT_MS = 60 * 1000; /** Maximum recursion depth when traversing dropped directories. */ private static readonly MAX_DIRECTORY_DEPTH = 32; /** Maximum total entries (files + directories) collected from a single drop. */ @@ -276,6 +300,23 @@ export class RdpFileTransferProvider { private activeDownloads: Map = new Map(); private uploadState?: UploadState; + // Upload paste-window watchdog. Armed when we advertise an upload, disarmed on the + // first FileContentsRequest (the remote pulled the files). Being armed is the single + // "advertised but not yet pulled" signal: on timeout it resumes monitoring and fails + // the never-pulled upload, and handleFormatListResponse consults it to fail a refused + // paste early (handleUnlock is a deliberate no-op: Unlock is not a reliable failure + // signal). Upload completion/failure run only after that first request, so they need + // not touch it. + private pasteAckTimeout?: ReturnType; + // Upload inactivity watchdog. Armed/reset on each FileContentsRequest once the remote + // starts pulling; fires if pulls stop for UPLOAD_INACTIVITY_TIMEOUT_MS (a stalled or + // clipboard-superseded paste) to release uploadState. Complements the paste-ack + // watchdog above, which only covers the "advertised but never pulled" case. + private uploadInactivityTimeout?: ReturnType; + // True between onUploadStarted (suppress monitoring) and onUploadFinished + // (resume), so resume fires exactly once even though it is now deferred until + // the paste is pulled, times out, fails, or the provider is disposed. + private uploadMonitoringSuppressed = false; // DroppedFile metadata retained after upload completes so re-paste works // without re-dropping. Cleared when a new upload starts or the manager is disposed. private retainedFiles?: DroppedFile[]; @@ -355,6 +396,7 @@ export class RdpFileTransferProvider { lockCallback((id: number) => this.handleLock(id)), unlockCallback((id: number) => this.handleUnlock(id)), locksExpiredCallback((ids: Uint32Array) => this.handleLocksExpired(ids)), + formatListResponseCallback((ok: boolean) => this.handleFormatListResponse(ok)), ]; } @@ -643,19 +685,23 @@ export class RdpFileTransferProvider { reject, }; - // Suppress clipboard monitoring briefly so the polling loop does not - // clobber our FormatList with a text/image update. Resume immediately - // after the FormatList is sent - the suppression window only needs to - // cover the race between suppressMonitoring() and the wire send. - // Upload state tracking continues independently via this.uploadState. - this.onUploadStarted?.(); + // Suppress clipboard monitoring so the 100ms polling loop cannot clobber our + // file FormatList with a stale text/image update during the paste window. It + // stays suppressed -- NOT just for the synchronous wire send, which left a + // race the monitor could win -- until we leave the advertised-but-not-pulled + // window: the remote pulls (first FileContentsRequest), or we give up on it + // (watchdog timeout or a rejected advertise). + this.suppressUploadMonitoring(); // Initiate file copy (broadcasts file list to remote) try { this.sendInitiateFileCopy(fileInfos); this.emit('upload-batch-started', transferIds, dropped); } catch (error) { + // Immediate failure: resume monitoring now and clear state so the + // next upload is not blocked. this.uploadState = undefined; + this.resumeUploadMonitoring(); const err: FileTransferError = { message: 'Failed to initiate file upload', direction: 'upload', @@ -663,17 +709,159 @@ export class RdpFileTransferProvider { }; this.emit('error', err); reject(new Error(err.message, { cause: error })); - } finally { - // Resume monitoring regardless of success/failure. The brief - // suppression window is intentionally short - just long enough - // to prevent the clipboard poll from racing with our FormatList. - this.onUploadFinished?.(); + return; } + + // FormatList is on the wire. Wait for the remote to pull the files; if it + // never does (paste landed in a non-file target, or the advertise was + // clobbered/rejected), the watchdog resumes monitoring and rejects this + // upload so its state cannot wedge later uploads ("Upload already in progress"). + this.armPasteAckWatchdog(); }); return { transferIds, completion }; } + /** Suppress clipboard monitoring for the duration of an upload's paste window. */ + private suppressUploadMonitoring(): void { + // Flip the flag before the callback so it holds even if the callback throws. + this.uploadMonitoringSuppressed = true; + try { + this.onUploadStarted?.(); + } catch (error) { + console.error('Error in onUploadStarted callback:', error); + } + } + + /** Resume clipboard monitoring (idempotent: fires onUploadFinished at most once + * per upload, since the resume point is now deferred past the wire send). */ + private resumeUploadMonitoring(): void { + if (!this.uploadMonitoringSuppressed) { + return; + } + // Flip the flag before the callback so it holds even if the callback throws. + this.uploadMonitoringSuppressed = false; + try { + this.onUploadFinished?.(); + } catch (error) { + console.error('Error in onUploadFinished callback:', error); + } + } + + /** Arm the paste-acknowledgment watchdog for the current upload. */ + private armPasteAckWatchdog(): void { + this.clearPasteAckWatchdog(); + // A new upload is starting: drop any inactivity watchdog left from a prior upload + // so it can't fire mid-way through this one. + this.clearUploadInactivityWatchdog(); + this.pasteAckTimeout = setTimeout(() => { + this.pasteAckTimeout = undefined; + // The remote never requested the files within the lock window. Resume + // monitoring and fail the pending upload so it cannot wedge later ones. + const state = this.uploadState; + if (state !== undefined && state.isRePaste !== true) { + this.failPendingUpload('The remote did not request the files in time, so the paste was not completed'); + } else { + this.resumeUploadMonitoring(); + } + }, RdpFileTransferProvider.PASTE_ACK_TIMEOUT_MS); + } + + /** Disarm the paste-acknowledgment watchdog, if armed. */ + private clearPasteAckWatchdog(): void { + if (this.pasteAckTimeout !== undefined) { + clearTimeout(this.pasteAckTimeout); + this.pasteAckTimeout = undefined; + } + } + + /** + * (Re)arm the upload inactivity watchdog (see {@link UPLOAD_INACTIVITY_TIMEOUT_MS}). + * Called on every FileContentsRequest, so continued pulls keep resetting it and a + * slow-but-progressing transfer is never killed; if pulls stop for the window the + * upload is failed (releasing uploadState). Skipped for re-pastes, matching the + * paste-ack watchdog. + */ + private resetUploadInactivityWatchdog(): void { + this.clearUploadInactivityWatchdog(); + this.uploadInactivityTimeout = setTimeout(() => { + this.uploadInactivityTimeout = undefined; + const state = this.uploadState; + if (state !== undefined && state.isRePaste !== true) { + this.failPendingUpload('The remote stopped requesting the files, so the paste did not complete'); + } + }, RdpFileTransferProvider.UPLOAD_INACTIVITY_TIMEOUT_MS); + } + + /** Disarm the upload inactivity watchdog, if armed. */ + private clearUploadInactivityWatchdog(): void { + if (this.uploadInactivityTimeout !== undefined) { + clearTimeout(this.uploadInactivityTimeout); + this.uploadInactivityTimeout = undefined; + } + } + + /** + * The remote acknowledged the paste by requesting file contents: the clobber + * window is over, so disarm the paste-ack watchdog and resume clipboard monitoring. + * Called on every FileContentsRequest; only the first disarms/resumes. Every request + * also (re)arms the inactivity watchdog so a stall after pulling began is recovered. + */ + private acknowledgePaste(): void { + if (this.pasteAckTimeout !== undefined) { + this.clearPasteAckWatchdog(); + this.resumeUploadMonitoring(); + } + this.resetUploadInactivityWatchdog(); + } + + /** + * Fail the in-flight upload (reject its completion, emit an upload error, clear + * uploadState) and resume monitoring. Used when the advertise is rejected or the + * paste is never pulled, so `uploadState` is released instead of lingering and + * throwing "Upload already in progress" on every later upload. + */ + private failPendingUpload(message: string): void { + this.clearPasteAckWatchdog(); + this.clearUploadInactivityWatchdog(); + this.resumeUploadMonitoring(); + const state = this.uploadState; + if (state === undefined) { + return; + } + // Abort any in-flight chunk reads and clear their timeouts before releasing the + // state (mirrors dispose()). Otherwise a read still running when the paste is torn + // down keeps its FileReader and a 60s reader-timeout alive for an upload that has + // already been rejected. + for (const timeout of state.readerTimeouts.values()) { + clearTimeout(timeout); + } + state.readerTimeouts.clear(); + for (const reader of state.activeReaders.values()) { + reader.abort(); + } + state.activeReaders.clear(); + + const err: FileTransferError = { message, direction: 'upload' }; + this.emit('error', err); + const { reject } = state; + this.uploadState = undefined; + reject(new Error(message)); + } + + /** + * Finalize a fully-accounted upload batch (every counted file either served in full + * or permanently failed). Stops the inactivity watchdog so it cannot linger past + * completion, retains the DroppedFile metadata so a re-paste from the remote can serve + * the data again, clears `uploadState`, and resolves the completion promise. + */ + private finishUploadBatch(state: UploadState): void { + this.clearUploadInactivityWatchdog(); + this.retainedFiles = state.droppedFiles; + this.uploadState = undefined; + state.resolve(); + } + /** * Show a file picker dialog and return selected files. * @@ -953,6 +1141,12 @@ export class RdpFileTransferProvider { dispose(): void { this.disposed = true; + // Stop the paste-ack watchdog and resume monitoring if an upload was still + // mid-paste-window (we defer the resume, so it may not have fired yet). + this.clearPasteAckWatchdog(); + this.clearUploadInactivityWatchdog(); + this.resumeUploadMonitoring(); + // Cancel active downloads (lock cleanup is handled by the Rust layer) for (const state of this.activeDownloads.values()) { state.chunks = []; @@ -988,6 +1182,16 @@ export class RdpFileTransferProvider { // ==================== Callback Handlers ==================== private handleFilesAvailable(files: FileInfo[], clipDataId?: number): void { + // A remote FormatList means the remote took ownership of the clipboard, which + // supersedes any in-flight upload advertise of ours: the remote will not pull our + // files, and once the paste has been acknowledged the paste-ack watchdog is + // already disarmed, so nothing else would ever release `uploadState`. Left + // lingering it wedges every later upload with "Upload already in progress". Release + // it here. This is the symmetric counterpart of handleFormatListResponse(false): + // that recovers when the remote *rejects* our advertise; this recovers when the + // remote *replaces* it. No-op when no upload is in flight. + this.failPendingUpload('Upload interrupted: the remote clipboard changed'); + // Do NOT cancel active downloads here. // // Per MS-RDPECLIP 2.2.4.1 and 3.1.5.3.2, clipboard locks ensure that @@ -1060,6 +1264,10 @@ export class RdpFileTransferProvider { } private handleFileContentsRequest(request: FileContentsRequest): void { + // The remote is pulling the files, so the paste was accepted: end the + // clobber-protection window (disarm the watchdog, resume monitoring). + this.acknowledgePaste(); + if (!this.uploadState) { if (!this.retainedFiles) { console.warn('Received file contents request but no upload in progress'); @@ -1147,10 +1355,7 @@ export class RdpFileTransferProvider { this.uploadState.failedFiles.add(request.index); this.uploadState.completedFiles.add(request.index); if (this.uploadState.completedFiles.size >= this.uploadState.expectedFileCount) { - const { resolve, droppedFiles: completed } = this.uploadState; - this.retainedFiles = completed; - this.uploadState = undefined; - resolve(); + this.finishUploadBatch(this.uploadState); } } }, RdpFileTransferProvider.FILE_READER_TIMEOUT_MS); @@ -1199,12 +1404,10 @@ export class RdpFileTransferProvider { } if (this.uploadState.completedFiles.size === this.uploadState.expectedFileCount) { - // All files uploaded successfully. Retain DroppedFile - // metadata so re-paste from the remote can serve data again. - const { resolve, droppedFiles: completed } = this.uploadState; - this.retainedFiles = completed; - this.uploadState = undefined; - resolve(); + // All files uploaded successfully. finishUploadBatch retains the + // DroppedFile metadata so a re-paste from the remote can serve the + // data again, and stops the inactivity watchdog. + this.finishUploadBatch(this.uploadState); } } } @@ -1243,10 +1446,7 @@ export class RdpFileTransferProvider { this.uploadState.failedFiles.add(request.index); this.uploadState.completedFiles.add(request.index); if (this.uploadState.completedFiles.size >= this.uploadState.expectedFileCount) { - const { resolve, droppedFiles: completed } = this.uploadState; - this.retainedFiles = completed; - this.uploadState = undefined; - resolve(); + this.finishUploadBatch(this.uploadState); } } }; @@ -1409,6 +1609,25 @@ export class RdpFileTransferProvider { } } + /** + * Handle the remote's response to one of our outbound Format Lists, surfaced via + * `on_format_list_response`. Always re-emitted as a `format-list-response` event so + * a frontend can drive paste from it (inject on accept, retry on reject). + * + * On reject (`ok === false`) the remote silently discards the advertised clipboard + * (MS-RDPECLIP), so an upload still waiting to be pulled can never complete -- + * previously this left `uploadState` set and every later upload threw "Upload + * already in progress". The watchdog-armed check scopes the release to an upload + * that was advertised but not yet pulled, so re-paste and an already-progressing + * transfer are left alone. + */ + private handleFormatListResponse(ok: boolean): void { + this.emit('format-list-response', ok); + if (!ok && this.pasteAckTimeout !== undefined) { + this.failPendingUpload('The remote rejected the file list, so the paste was not accepted'); + } + } + private handleLock(_dataId: number): void { // Remote locked their clipboard (informational only for uploads). } diff --git a/web-client/iron-remote-desktop-rdp/src/extensions.ts b/web-client/iron-remote-desktop-rdp/src/extensions.ts index 5eafe364a6..8881a7646f 100644 --- a/web-client/iron-remote-desktop-rdp/src/extensions.ts +++ b/web-client/iron-remote-desktop-rdp/src/extensions.ts @@ -45,6 +45,10 @@ export function locksExpiredCallback(cb: (clipDataIds: Uint32Array) => void): Ex return new Extension('locks_expired_callback', cb as unknown); } +export function formatListResponseCallback(cb: (ok: boolean) => void): Extension { + return new Extension('format_list_response_callback', cb as unknown); +} + // Virtual printer (RDPDR) extensions // // Registering `printJobStreamCallbacks` activates the browser-side virtual From f38554277a1af3085c1fa5739cda515939d09abf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:49:31 +0000 Subject: [PATCH 281/325] fix(dvc-pipe-proxy): remove trailing punctuation from log messages (#1380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Benoît Cortier <3809077+CBenoit@users.noreply.github.com> --- .../ironrdp-dvc-pipe-proxy/src/platform/unix.rs | 4 ++-- crates/ironrdp-dvc-pipe-proxy/src/worker.rs | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs b/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs index 7c49d07045..11c8888a85 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs @@ -21,7 +21,7 @@ impl OsPipe for UnixPipe { debug!( %pipe_name, - "DVC pipe already exists, removing stale file." + "DVC pipe already exists, removing stale file" ); // Just to be sure, check if it's indeed a socket - @@ -38,7 +38,7 @@ impl OsPipe for UnixPipe { Err(e) if e.kind() == std::io::ErrorKind::NotFound => { trace!( %pipe_name, - "DVC pipe does not exist, creating it." + "DVC pipe does not exist, creating it" ); } Err(e) => { diff --git a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs index ec21f0e64f..e7c3ace6dd 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs @@ -40,7 +40,7 @@ pub(crate) fn run_worker(ctx: WorkerCtx) { %channel_name, %pipe_name, ?error, - "DVC pipe proxy worker thread initialization failed." + "DVC pipe proxy worker thread initialization failed" ); return; } @@ -51,7 +51,7 @@ pub(crate) fn run_worker(ctx: WorkerCtx) { %channel_name, %pipe_name, ?error, - "DVC pipe proxy worker thread has failed." + "DVC pipe proxy worker thread has failed" ); } }); @@ -77,11 +77,11 @@ async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result { - debug!(%channel_name, %pipe_name,"DVC proxy worker thread has started."); + debug!(%channel_name, %pipe_name, "DVC proxy worker thread has started"); pipe? } _ = ctx.abort_event.notified() => { - debug!(%channel_name, %pipe_name, "DVC proxy worker thread has been aborted."); + debug!(%channel_name, %pipe_name, "DVC proxy worker thread has been aborted"); return Ok(NextWorkerState::Abort); } }; @@ -95,7 +95,7 @@ async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result { - debug!(%channel_name, %pipe_name, "Received abort signal for DVC proxy worker thread."); + debug!(%channel_name, %pipe_name, "Received abort signal for DVC proxy worker thread"); return Ok(NextWorkerState::Abort); } read_bytes_result = read_pipe => { @@ -124,7 +124,7 @@ async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result data, None => { - debug!(%channel_name, %pipe_name, "DVC mpsc channel returned EOF."); + debug!(%channel_name, %pipe_name, "DVC mpsc channel returned EOF"); // Server DVC has been closed, there is no point in // trying to reconnect. return Ok(NextWorkerState::Abort); @@ -180,7 +180,7 @@ async fn worker(ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { debug!( channel_name = %bridged_ctx.channel_name, pipe_name = %bridged_ctx.pipe_name, - "Aborting DVC proxy worker thread." + "Abort DVC proxy worker thread" ); break; } @@ -188,7 +188,7 @@ async fn worker(ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { debug!( channel_name = %bridged_ctx.channel_name, pipe_name = %bridged_ctx.pipe_name, - "Reconnecting to DVC pipe..." + "Reconnect to DVC pipe" ); continue; } From f12bbce5508f27c93c079d04e6a149627e2eb0a5 Mon Sep 17 00:00:00 2001 From: Yuval Marcus Date: Tue, 23 Jun 2026 14:22:54 -0400 Subject: [PATCH 282/325] feat(web): supersede a stuck upload and complete zero-byte files (#1381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the web `RdpFileTransferProvider` (in `iron-remote-desktop-rdp`) to make uploads more resilient: starting a new upload now supersedes any in-progress batch, and empty (0-byte) files can complete without ever receiving a RANGE request, preventing the provider’s `uploadState` from becoming stuck. --- .../src/RdpFileTransferProvider.test.ts | 211 +++++++++++++++++- .../src/RdpFileTransferProvider.ts | 127 +++++++---- 2 files changed, 296 insertions(+), 42 deletions(-) diff --git a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts index 0e9469270e..9b7c56a7e3 100644 --- a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts +++ b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts @@ -526,14 +526,217 @@ describe('RdpFileTransferProvider', () => { // @ts-expect-error - private callback the WASM layer drives via unlockCallback p.handleUnlock(0); - // No error, and the upload state is intact (a second attempt still hits the guard). + // No error, and the upload state is intact (Unlock is ignored). expect(errorHandler).not.toHaveBeenCalled(); - expect(() => p.uploadFiles(files)).toThrow('Upload already in progress'); + expect(p.isUploadInProgress()).toBe(true); p.dispose(); await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); }); + it('completes a 0-byte file from its SIZE request alone (no RANGE follows)', async () => { + // A 0-byte file has no bytes, so the remote asks for its size and never + // sends a RANGE -- the only path that otherwise marks a file complete. + // Without explicit handling the batch never reaches expectedFileCount, + // finishUploadBatch never runs, and uploadState wedges every later upload. + const { provider: p, session: s } = setupProvider(); + const errorHandler = vi.fn(); + const completeHandler = vi.fn(); + p.on('error', errorHandler); + p.on('upload-complete', completeHandler); + + const files = [new File([], 'empty.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + + // Remote requests only the size (8 bytes) for the lone empty file. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + + await expect(completion).resolves.toBeUndefined(); + expect(completeHandler).toHaveBeenCalledTimes(1); + expect(errorHandler).not.toHaveBeenCalled(); + + // finishUploadBatch released uploadState: a fresh upload is not wedged + // ("Upload already in progress"). Reaching a handle plus the + // initiateFileCopy call proves it restarted. + s.invokeExtension.mockClear(); + const second = p.uploadFiles([new File(['x'], 'y.txt', { type: 'text/plain' })]); + expect(s.invokeExtension).toHaveBeenCalledTimes(1); + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('lets a 0-byte file finish a mixed batch after the data files are served', async () => { + // The realistic case: a folder of tiny files where the empty ones are the + // last to "complete". The empty file's SIZE request must finish the batch + // once every data file has been fully served. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + const completeHandler = vi.fn(); + p.on('error', errorHandler); + p.on('upload-complete', completeHandler); + + const files = [ + new File(['data'], 'a.txt', { type: 'text/plain' }), + new File([], 'empty.txt', { type: 'text/plain' }), + ]; + const { completion } = p.uploadFiles(files); + let resolved = false; + void completion.then(() => { + resolved = true; + }); + + // Data file: SIZE then RANGE. jsdom runs the FileReader on a timer, so + // flush it -- index 0 completes but the batch is not done (1 of 2). + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 2, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + expect(resolved).toBe(false); + + // Empty file: SIZE only. This completes the second (and last) counted + // file, so the batch finishes. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 3, index: 1, flags: 1, position: 0, size: 8 }); + await Promise.resolve(); + expect(resolved).toBe(true); + + expect(completeHandler).toHaveBeenCalledTimes(2); + const completedNames = completeHandler.mock.calls.map((c) => (c[0] as File).name); + expect(completedNames).toContain('empty.txt'); + expect(errorHandler).not.toHaveBeenCalled(); + + // finishUploadBatch disarmed the inactivity watchdog: no timer lingers. + expect(vi.getTimerCount()).toBe(0); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('completes a directory-only batch on paste-ack without waiting for the inactivity watchdog', async () => { + // A directory entry carries no data: the remote requests SIZE (answered with 0) but + // never a RANGE, so no file is ever marked complete and expectedFileCount is 0. Without + // explicit handling, finishUploadBatch never runs, the inactivity watchdog fires after + // 60s, and an empty-folder paste that actually succeeded is reported as a failure. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const { completion } = p.uploadFiles([ + { file: null, name: 'folder', size: 0, lastModified: 0, isDirectory: true }, + ]); + let resolved = false; + void completion.then(() => { + resolved = true; + }); + expect(p.isUploadInProgress()).toBe(true); + + // Remote acknowledges the paste by requesting the directory's size. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + await Promise.resolve(); + expect(resolved).toBe(true); + expect(errorHandler).not.toHaveBeenCalled(); + + // The batch finished on acknowledgment, so the inactivity watchdog never fires: + // letting the full window elapse must not surface an error. + await vi.advanceTimersByTimeAsync(60_000); + expect(errorHandler).not.toHaveBeenCalled(); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('reports isUploadInProgress across an upload lifecycle (idle -> advertised -> complete)', async () => { + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + expect(p.isUploadInProgress()).toBe(false); + + const { completion } = p.uploadFiles([new File(['data'], 'x.txt', { type: 'text/plain' })]); + expect(p.isUploadInProgress()).toBe(true); + + // Remote pulls the whole file -> batch completes -> upload state released. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + await completion; + expect(p.isUploadInProgress()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('clears isUploadInProgress once a stalled upload is failed', async () => { + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const { completion } = p.uploadFiles([new File(['x'], 'x.txt', { type: 'text/plain' })]); + const rejected = expect(completion).rejects.toThrow(); + expect(p.isUploadInProgress()).toBe(true); + + // Remote never pulls -> the watchdog fails the upload and releases the state. + await vi.advanceTimersByTimeAsync(60_000); + await rejected; + expect(p.isUploadInProgress()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('reports isUploadInProgress again during a remote re-paste', async () => { + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const { completion } = p.uploadFiles([new File(['data'], 'x.txt', { type: 'text/plain' })]); + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + await completion; + expect(p.isUploadInProgress()).toBe(false); + + // The remote re-pastes the retained file: state is rebuilt, so it's in progress again. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 2, index: 0, flags: 1, position: 0, size: 8 }); + expect(p.isUploadInProgress()).toBe(true); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('supersedes an in-flight upload when a new one starts (old completion resolves, new batch advertised)', async () => { + const { provider: p, session: s } = setupProvider(); + const { completion: firstCompletion } = p.uploadFiles([ + new File(['data'], 'first.txt', { type: 'text/plain' }), + ]); + expect(p.isUploadInProgress()).toBe(true); + + // A new paste arrives while the first is still advertised. The old completion resolves + // cleanly and the new batch is advertised in its place. + s.invokeExtension.mockClear(); + const { completion: secondCompletion } = p.uploadFiles([ + new File(['more'], 'second.txt', { type: 'text/plain' }), + ]); + + await expect(firstCompletion).resolves.toBeUndefined(); + expect(p.isUploadInProgress()).toBe(true); // now the second batch + expect(s.invokeExtension).toHaveBeenCalledTimes(1); // a fresh initiateFileCopy + + p.dispose(); + await expect(secondCompletion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + it('should call onUploadFinished even on initiateFileCopy failure', async () => { const onUploadStarted = vi.fn(); const onUploadFinished = vi.fn(); @@ -617,8 +820,8 @@ describe('RdpFileTransferProvider', () => { fireFormatListResponse(p, true); - // Upload is still in progress: a second attempt still hits the guard. - expect(() => p.uploadFiles(files)).toThrow('Upload already in progress'); + // Upload is still in progress. + expect(p.isUploadInProgress()).toBe(true); p.dispose(); await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); diff --git a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts index e7b48656de..f505e9e532 100644 --- a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts +++ b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts @@ -634,8 +634,10 @@ export class RdpFileTransferProvider { * ``` */ uploadFiles(files: File[] | DroppedFile[]): UploadHandle { + // A new paste supersedes the previous offer (MS-RDPECLIP §3.1.1.1), so tear down any + // existing batch instead of throwing. if (this.uploadState !== undefined) { - throw new Error('Upload already in progress'); + this.supersedeUpload(); } // New upload supersedes any retained files from a previous batch @@ -722,8 +724,18 @@ export class RdpFileTransferProvider { return { transferIds, completion }; } - /** Suppress clipboard monitoring for the duration of an upload's paste window. */ + /** Whether an upload batch is currently advertised or in flight (a fresh upload, or a re-paste + * rebuilt from retained files). */ + isUploadInProgress(): boolean { + return this.uploadState !== undefined; + } + + /** Suppress clipboard monitoring for an upload's paste window. Idempotent: a supersede keeps + * monitoring suppressed across the old->new upload, so this must not re-fire `onUploadStarted`. */ private suppressUploadMonitoring(): void { + if (this.uploadMonitoringSuppressed) { + return; + } // Flip the flag before the callback so it holds even if the callback throws. this.uploadMonitoringSuppressed = true; try { @@ -812,9 +824,38 @@ export class RdpFileTransferProvider { this.clearPasteAckWatchdog(); this.resumeUploadMonitoring(); } + + // A directory-only batch (expectedFileCount === 0) gets a SIZE request per directory but + // never a RANGE -- the only path that marks a file complete -- so finishUploadBatch would + // never run and the inactivity watchdog below would fail an upload that actually succeeded. + // The remote acknowledging the paste is the only completion signal a data-less batch can + // get, so finish it now. (Mirrors the 0-byte file SIZE-path completion; here there are no + // counted files at all.) Re-pastes are left alone, like the watchdog itself. + const state = this.uploadState; + if (state !== undefined && state.isRePaste !== true && state.expectedFileCount === 0) { + this.finishUploadBatch(state); + return; + } + this.resetUploadInactivityWatchdog(); } + /** + * Abort any in-flight chunk reads for a batch and clear their timeouts. Without this, a read + * still running when the batch is torn down keeps its FileReader and a 60s reader-timeout + * alive. Shared by every upload teardown path (fail, supersede, dispose). + */ + private abortInFlightReads(state: UploadState): void { + for (const timeout of state.readerTimeouts.values()) { + clearTimeout(timeout); + } + state.readerTimeouts.clear(); + for (const reader of state.activeReaders.values()) { + reader.abort(); + } + state.activeReaders.clear(); + } + /** * Fail the in-flight upload (reject its completion, emit an upload error, clear * uploadState) and resume monitoring. Used when the advertise is rejected or the @@ -829,18 +870,7 @@ export class RdpFileTransferProvider { if (state === undefined) { return; } - // Abort any in-flight chunk reads and clear their timeouts before releasing the - // state (mirrors dispose()). Otherwise a read still running when the paste is torn - // down keeps its FileReader and a 60s reader-timeout alive for an upload that has - // already been rejected. - for (const timeout of state.readerTimeouts.values()) { - clearTimeout(timeout); - } - state.readerTimeouts.clear(); - for (const reader of state.activeReaders.values()) { - reader.abort(); - } - state.activeReaders.clear(); + this.abortInFlightReads(state); const err: FileTransferError = { message, direction: 'upload' }; this.emit('error', err); @@ -849,6 +879,25 @@ export class RdpFileTransferProvider { reject(new Error(message)); } + /** + * Tear down the current upload because a new paste is replacing it. Unlike + * {@link failPendingUpload}, this emits no error and leaves monitoring suppressed (a new upload + * is starting); it aborts in-flight reads, clears the state, and *resolves* the old completion + * (a replacement, not a failure). + */ + private supersedeUpload(): void { + this.clearPasteAckWatchdog(); + this.clearUploadInactivityWatchdog(); + const state = this.uploadState; + if (state === undefined) { + return; + } + this.abortInFlightReads(state); + const { resolve } = state; + this.uploadState = undefined; + resolve(); + } + /** * Finalize a fully-accounted upload batch (every counted file either served in full * or permanently failed). Stops the inactivity watchdog so it cannot linger past @@ -1156,16 +1205,7 @@ export class RdpFileTransferProvider { // Clean up active FileReaders, clear timeouts, and reject upload promise if (this.uploadState !== undefined) { - for (const timeout of this.uploadState.readerTimeouts.values()) { - clearTimeout(timeout); - } - this.uploadState.readerTimeouts.clear(); - - for (const reader of this.uploadState.activeReaders.values()) { - reader.abort(); - } - this.uploadState.activeReaders.clear(); - + this.abortInFlightReads(this.uploadState); this.uploadState.reject(new Error('RdpFileTransferProvider disposed')); } this.uploadState = undefined; @@ -1322,6 +1362,13 @@ export class RdpFileTransferProvider { const view = new DataView(sizeBytes.buffer); view.setBigUint64(0, BigInt(file.size), true); this.sendSubmitFileContents(request.streamId, false, sizeBytes); + + // A 0-byte file gets no RANGE request (no bytes to read), so complete it here -- + // otherwise it never reaches completedFiles and the batch never finishes. Mirrors + // the download path's size===0 handling. + if (file.size === 0) { + this.markUploadFileComplete(request.index, file, state.transferIds.get(request.index) ?? -1); + } } else if ((request.flags & FileContentsFlags.RANGE) !== 0) { // RANGE request: read file chunk const chunk = file.slice(request.position, request.position + request.size); @@ -1395,20 +1442,7 @@ export class RdpFileTransferProvider { // Check if all bytes for this file have been served if (served >= file.size) { - const alreadyCompleted = this.uploadState.completedFiles.has(request.index); - - this.uploadState.completedFiles.add(request.index); - - if (!alreadyCompleted) { - this.emit('upload-complete', file, request.index, uploadTransferId); - } - - if (this.uploadState.completedFiles.size === this.uploadState.expectedFileCount) { - // All files uploaded successfully. finishUploadBatch retains the - // DroppedFile metadata so a re-paste from the remote can serve the - // data again, and stops the inactivity watchdog. - this.finishUploadBatch(this.uploadState); - } + this.markUploadFileComplete(request.index, file, uploadTransferId); } } }; @@ -1455,6 +1489,23 @@ export class RdpFileTransferProvider { } } + /** + * Mark a single upload file as fully served: record it, emit upload-complete once, and + * finalize the batch once every counted file is accounted for. Shared by the RANGE onload + * path (final chunk served) and the SIZE path for 0-byte files (which get no RANGE request). + */ + private markUploadFileComplete(index: number, file: File, transferId: number): void { + const state = this.uploadState; + if (state === undefined || state.completedFiles.has(index)) { + return; + } + state.completedFiles.add(index); + this.emit('upload-complete', file, index, transferId); + if (state.completedFiles.size === state.expectedFileCount) { + this.finishUploadBatch(state); + } + } + /** * Lazily rebuild uploadState from retainedFiles when the remote re-pastes * after the original upload completed. This lets the main code path in From af2706d09362c15260b2e54d43e1dcbafa05162c Mon Sep 17 00:00:00 2001 From: Gabriel Bauman <967743+gabrielbauman@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:52:05 -0700 Subject: [PATCH 283/325] feat(web): add pluggable storage backends for RDP file downloads (#1221) Adds a pluggable download storage layer to `iron-remote-desktop-rdp` so large RDP file downloads can stream to persistent storage (OPFS) instead of buffering all chunks in RAM, while keeping a universal in-memory Blob fallback. --- .../src/RdpFileTransferProvider.test.ts | 654 ++++++++++++++++++ .../src/RdpFileTransferProvider.ts | 309 ++++++++- .../iron-remote-desktop-rdp/src/main.ts | 9 + .../src/storage/BlobStorageBackend.ts | 69 ++ .../src/storage/FileStorageBackend.ts | 85 +++ .../src/storage/OpfsStorageBackend.ts | 301 ++++++++ .../src/storage/detect.ts | 62 ++ .../src/storage/index.ts | 5 + .../src/storage/storage.test.ts | 620 +++++++++++++++++ 9 files changed, 2079 insertions(+), 35 deletions(-) create mode 100644 web-client/iron-remote-desktop-rdp/src/storage/BlobStorageBackend.ts create mode 100644 web-client/iron-remote-desktop-rdp/src/storage/FileStorageBackend.ts create mode 100644 web-client/iron-remote-desktop-rdp/src/storage/OpfsStorageBackend.ts create mode 100644 web-client/iron-remote-desktop-rdp/src/storage/detect.ts create mode 100644 web-client/iron-remote-desktop-rdp/src/storage/index.ts create mode 100644 web-client/iron-remote-desktop-rdp/src/storage/storage.test.ts diff --git a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts index 9b7c56a7e3..a3e7045749 100644 --- a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts +++ b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts @@ -1095,3 +1095,657 @@ describe('RdpFileTransferProvider', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Constructor validation for storageBackend option +// --------------------------------------------------------------------------- + +describe('RdpFileTransferProvider storageBackend validation', () => { + it('rejects an object missing createWriteHandle', () => { + expect( + () => + new RdpFileTransferProvider({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storageBackend: { dispose: async () => {} } as any, + }), + ).toThrow(/createWriteHandle\(\) and dispose\(\)/); + }); + + it('rejects an object missing dispose', () => { + expect( + () => + new RdpFileTransferProvider({ + storageBackend: { + createWriteHandle: async () => ({}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }), + ).toThrow(/createWriteHandle\(\) and dispose\(\)/); + }); + + it('rejects an invalid preference string', () => { + expect( + () => + new RdpFileTransferProvider({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storageBackend: 'opfs' as any, + }), + ).toThrow(/invalid preference 'opfs'/); + }); + + it('accepts a valid FileStorageBackend object', () => { + expect( + () => + new RdpFileTransferProvider({ + storageBackend: { + name: 'test', + createWriteHandle: async () => ({}) as never, + dispose: async () => {}, + }, + }), + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Download flow with injected storage backend +// --------------------------------------------------------------------------- + +import type { FileStorageBackend, FileWriteHandle } from './storage'; + +/** + * Helper to build an 8-byte little-endian SIZE response for a given file size. + */ +function makeSizeResponse(streamId: number, size: number): { streamId: number; isError: boolean; data: Uint8Array } { + const buf = new ArrayBuffer(8); + new DataView(buf).setBigUint64(0, BigInt(size), true); + return { streamId, isError: false, data: new Uint8Array(buf) }; +} + +function makeDataResponse(streamId: number, bytes: number[]): { streamId: number; isError: boolean; data: Uint8Array } { + return { streamId, isError: false, data: new Uint8Array(bytes) }; +} + +function makeErrorResponse(streamId: number): { streamId: number; isError: boolean; data: Uint8Array } { + return { streamId, isError: true, data: new Uint8Array(0) }; +} + +/** + * Create a mock FileStorageBackend that records all operations. + */ +function createMockStorageBackend(options?: { + createWriteHandleError?: Error; + writeError?: Error; + finalizeError?: Error; +}) { + const writes: Uint8Array[] = []; + let finalized = false; + let aborted = false; + let bytesWritten = 0; + + const writeHandle: FileWriteHandle = { + get bytesWritten() { + return bytesWritten; + }, + async write(chunk: Uint8Array) { + if (options?.writeError) throw options.writeError; + writes.push(new Uint8Array(chunk)); + bytesWritten += chunk.length; + }, + async finalize() { + if (options?.finalizeError) throw options.finalizeError; + finalized = true; + return new Blob(writes); + }, + async abort() { + aborted = true; + }, + }; + + const backend: FileStorageBackend = { + name: 'mock', + async createWriteHandle(_fileName: string, _expectedSize: number) { + if (options?.createWriteHandleError) throw options.createWriteHandleError; + return writeHandle; + }, + async dispose() {}, + }; + + return { + backend, + writeHandle, + get writes() { + return writes; + }, + get finalized() { + return finalized; + }, + get aborted() { + return aborted; + }, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type CallbackMap = Record void>; + +/** + * Set up a provider with an injected storage backend and extract the + * protocol callbacks from getBuilderExtensions(). + */ +function setupDownloadTest(backendOrOptions?: FileStorageBackend | Parameters[0]) { + let mockStorage: ReturnType; + let backend: FileStorageBackend; + + if (backendOrOptions !== undefined && 'name' in backendOrOptions) { + backend = backendOrOptions; + mockStorage = undefined as unknown as ReturnType; + } else { + mockStorage = createMockStorageBackend(backendOrOptions); + backend = mockStorage.backend; + } + + const provider = new RdpFileTransferProvider({ + chunkSize: 4, // small chunks for testing + storageBackend: backend, + }); + + const session = new MockSession(); + provider.setSession(session); + + // Extract callback functions from the mocked extensions + const extensions = provider.getBuilderExtensions(); + const callbacks: CallbackMap = {}; + for (const ext of extensions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const e = ext as any; + if (typeof e.value === 'function') { + callbacks[e.ident] = e.value; + } + } + + return { provider, session, callbacks, mockStorage }; +} + +describe('download flow with storage backend', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('happy path: SIZE + DATA -> write -> finalize -> resolve', async () => { + const { provider, session, callbacks, mockStorage } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'test.bin', size: 6, lastModified: 0 }; + + // Announce files available + callbacks['files_available_callback']([fileInfo]); + + // Start download + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + expect(transferId).toBeGreaterThan(0); + + // Session should have received the SIZE request + expect(session.invokeExtension).toHaveBeenCalledTimes(1); + + // Feed SIZE response (6 bytes) + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 6)); + + // Wait a tick for the async write handle init to complete + await new Promise((r) => setTimeout(r, 10)); + + // Session should now have received a RANGE request + expect(session.invokeExtension.mock.calls.length).toBeGreaterThanOrEqual(2); + + // Feed DATA responses (chunkSize=4, so two chunks: 4+2) + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [5, 6])); + await new Promise((r) => setTimeout(r, 10)); + + // Download should complete + const blob = await completion; + expect(blob.size).toBe(6); + expect(mockStorage.finalized).toBe(true); + expect(mockStorage.writes).toHaveLength(2); + + provider.dispose(); + }); + + it('empty file (size=0) resolves without creating a write handle', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'empty.bin', size: 0, lastModified: 0 }; + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Feed SIZE response: 0 bytes + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 0)); + + const blob = await completion; + expect(blob.size).toBe(0); + // Write handle should NOT have been created for empty files + expect(mockStorage.finalized).toBe(false); + expect(mockStorage.writes).toHaveLength(0); + + provider.dispose(); + }); + + it('remote error response rejects the download', async () => { + const { provider, callbacks } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'fail.bin', size: 100, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Feed error response + callbacks['file_contents_response_callback'](makeErrorResponse(transferId)); + + await expect(completion).rejects.toThrow('Remote failed to provide file contents'); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].direction).toBe('download'); + + provider.dispose(); + }); + + it('storage backend init failure emits error', async () => { + const { provider, callbacks } = setupDownloadTest({ + createWriteHandleError: new Error('disk full'), + }); + const fileInfo: FileInfo = { name: 'fail.bin', size: 100, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Feed SIZE response to trigger write handle creation + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 100)); + + // Wait for the async init to fail + await expect(completion).rejects.toThrow('Failed to initialize storage for download'); + expect(errorHandler).toHaveBeenCalledTimes(1); + + provider.dispose(); + }); + + it('write failure emits error and aborts', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest({ + writeError: new Error('I/O error'), + }); + const fileInfo: FileInfo = { name: 'fail.bin', size: 4, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach a rejection handler immediately to prevent unhandled rejection + const completionResult = completion.catch((e: unknown) => e); + + // Feed SIZE response + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + await new Promise((r) => setTimeout(r, 10)); + + // Feed DATA response -- the write will fail + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Failed to write download chunk to storage'); + expect(mockStorage.aborted).toBe(true); + + provider.dispose(); + }); + + it('QuotaExceededError produces a specific error message', async () => { + const quotaError = new DOMException('quota exceeded', 'QuotaExceededError'); + const { provider, callbacks } = setupDownloadTest({ + writeError: quotaError, + }); + const fileInfo: FileInfo = { name: 'big.bin', size: 4, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach a rejection handler immediately to prevent unhandled rejection + const completionResult = completion.catch((e: unknown) => e); + + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/[Ss]torage quota exceeded/); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toMatch(/[Ss]torage quota exceeded/); + + provider.dispose(); + }); + + it('dispose during download aborts write handle', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'partial.bin', size: 100, lastModified: 0 }; + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Feed SIZE response and wait for write handle init + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 100)); + await new Promise((r) => setTimeout(r, 10)); + + // Feed one chunk + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + // Dispose mid-download + provider.dispose(); + + await expect(completion).rejects.toThrow('disposed'); + expect(mockStorage.aborted).toBe(true); + }); + + it('emits download-progress during transfer', async () => { + const { provider, callbacks } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'progress.bin', size: 8, lastModified: 0 }; + const progressEvents: Array<{ bytesTransferred: number; percentage: number }> = []; + + provider.on('download-progress', (p) => progressEvents.push(p)); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 8)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [5, 6, 7, 8])); + await new Promise((r) => setTimeout(r, 10)); + + await completion; + + expect(progressEvents.length).toBeGreaterThanOrEqual(2); + expect(progressEvents[0].bytesTransferred).toBe(4); + expect(progressEvents[0].percentage).toBe(50); + expect(progressEvents[progressEvents.length - 1].percentage).toBe(100); + + provider.dispose(); + }); + + it('emits download-complete on success', async () => { + const { provider, callbacks } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'done.bin', size: 2, lastModified: 0 }; + const completeEvents: Array<{ fileInfo: FileInfo; blob: Blob }> = []; + + provider.on('download-complete', (fi, blob) => completeEvents.push({ fileInfo: fi, blob })); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 2)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [0xca, 0xfe])); + await new Promise((r) => setTimeout(r, 10)); + + await completion; + + expect(completeEvents).toHaveLength(1); + expect(completeEvents[0].fileInfo.name).toBe('done.bin'); + expect(completeEvents[0].blob.size).toBe(2); + + provider.dispose(); + }); +}); + +// --------------------------------------------------------------------------- +// Write-handle-ready race path tests +// --------------------------------------------------------------------------- + +describe('download flow with delayed write handle init', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + /** + * Create a mock backend whose createWriteHandle resolves after an + * explicit trigger, simulating slow OPFS init. + */ + function createDelayedBackend() { + const writes: Uint8Array[] = []; + let finalized = false; + let aborted = false; + let bytesWritten = 0; + let resolveInit!: () => void; + let rejectInit!: (err: Error) => void; + + const initPromise = new Promise((resolve, reject) => { + resolveInit = resolve; + rejectInit = reject; + }); + + const writeHandle: FileWriteHandle = { + get bytesWritten() { + return bytesWritten; + }, + async write(chunk: Uint8Array) { + writes.push(new Uint8Array(chunk)); + bytesWritten += chunk.length; + }, + async finalize() { + finalized = true; + return new Blob(writes); + }, + async abort() { + aborted = true; + }, + }; + + const backend: FileStorageBackend = { + name: 'delayed-mock', + async createWriteHandle(_fileName: string, _expectedSize: number) { + await initPromise; + return writeHandle; + }, + async dispose() {}, + }; + + return { + backend, + /** Call to let createWriteHandle resolve. */ + resolveInit, + /** Call to make createWriteHandle fail. */ + rejectInit, + get writes() { + return writes; + }, + get finalized() { + return finalized; + }, + get aborted() { + return aborted; + }, + }; + } + + it('DATA arriving before write handle is ready awaits init then writes', async () => { + const delayed = createDelayedBackend(); + const { provider, callbacks } = setupDownloadTest(delayed.backend); + const fileInfo: FileInfo = { name: 'race.bin', size: 4, lastModified: 0 }; + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // SIZE response triggers write handle init (which blocks on initPromise) + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + + // DATA arrives while write handle init is still pending + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + + // Let microtasks settle -- handleDataChunk should be awaiting writeHandleReady + await new Promise((r) => setTimeout(r, 10)); + + // Writes should NOT have happened yet + expect(delayed.writes).toHaveLength(0); + + // Now release the init + delayed.resolveInit(); + await new Promise((r) => setTimeout(r, 10)); + + // Download should complete + const blob = await completion; + expect(blob.size).toBe(4); + expect(delayed.writes).toHaveLength(1); + expect(delayed.finalized).toBe(true); + + provider.dispose(); + }); + + it('init failure while DATA is waiting does not hang', async () => { + const delayed = createDelayedBackend(); + const { provider, callbacks } = setupDownloadTest(delayed.backend); + const fileInfo: FileInfo = { name: 'fail-race.bin', size: 4, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach rejection handler early to prevent unhandled rejection warning. + const completionResult = completion.catch((e: unknown) => e); + + // SIZE response triggers write handle init + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + + // DATA arrives while init is pending + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + + // Fail the init + delayed.rejectInit(new Error('OPFS broken')); + await new Promise((r) => setTimeout(r, 10)); + + // Download should reject (not hang) + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Failed to initialize storage for download'); + expect(delayed.writes).toHaveLength(0); + expect(errorHandler).toHaveBeenCalledTimes(1); + + provider.dispose(); + }); + + it('dispose during pending write-handle init does not leak', async () => { + const delayed = createDelayedBackend(); + const { provider, callbacks } = setupDownloadTest(delayed.backend); + const fileInfo: FileInfo = { name: 'dispose-race.bin', size: 100, lastModified: 0 }; + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // SIZE response triggers write handle init (blocked) + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 100)); + await new Promise((r) => setTimeout(r, 5)); + + // Dispose before init completes + provider.dispose(); + + await expect(completion).rejects.toThrow('disposed'); + + // Resolve init after dispose -- should not cause errors + delayed.resolveInit(); + await new Promise((r) => setTimeout(r, 10)); + + // No writes should have occurred + expect(delayed.writes).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Edge case error path tests +// --------------------------------------------------------------------------- + +describe('download flow edge cases', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('finalize() failure emits error and rejects', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest({ + finalizeError: new Error('disk corruption'), + }); + const fileInfo: FileInfo = { name: 'corrupt.bin', size: 4, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach rejection handler early. + const completionResult = completion.catch((e: unknown) => e); + + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Failed to finalize downloaded file'); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toBe('Failed to finalize downloaded file'); + expect(mockStorage.aborted).toBe(true); + + provider.dispose(); + }); + + it('lock expiration aborts affected downloads', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'locked.bin', size: 100, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + // Announce files with a clipDataId (lock ID). + callbacks['files_available_callback']([fileInfo], 42); + + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach rejection handler early. + const completionResult = completion.catch((e: unknown) => e); + + // Feed SIZE and first chunk. + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 100)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + // Expire the lock. + callbacks['locks_expired_callback'](new Uint32Array([42])); + + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/timed out/i); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toMatch(/timed out/i); + expect(mockStorage.aborted).toBe(true); + + provider.dispose(); + }); +}); diff --git a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts index f505e9e532..6a08162f0d 100644 --- a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts +++ b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts @@ -13,6 +13,9 @@ import { submitFileContents, initiateFileCopy, } from './extensions'; +import type { FileStorageBackend, FileWriteHandle } from './storage'; +import { detectStorageBackend } from './storage'; +import type { StorageBackendPreference } from './storage'; /** * Minimal session interface for extension-based file transfer. @@ -44,6 +47,21 @@ export interface RdpFileTransferProviderOptions { * Use this to resume clipboard monitoring after {@link onUploadStarted}. */ onUploadFinished?: () => void; + + /** + * Storage backend for downloads. + * + * Accepts either a preference string or a pre-constructed backend + * instance (useful for testing or custom backends): + * + * - `'auto'` (default): use OPFS when available, fall back to + * in-memory Blob. OPFS reduces peak RAM from ~2x file size to + * ~chunk size. + * - `'blob'`: force in-memory Blob storage. + * - `FileStorageBackend`: use the provided backend instance directly, + * bypassing auto-detection. + */ + storageBackend?: StorageBackendPreference | FileStorageBackend; } /** @@ -143,7 +161,12 @@ interface TransferState { streamId: number; clipDataId?: number; expectedSize?: number; - chunks: Uint8Array[]; + writeHandle?: FileWriteHandle; + /** Resolves when `writeHandle` has been assigned. Always resolves (never + * rejects) so that concurrent `handleDataChunk` callers awaiting this + * promise do not need individual error handling -- failures are detected + * via the `!state.writeHandle` guard after the await. */ + writeHandleReady?: Promise; bytesReceived: number; resolve: (blob: Blob) => void; reject: (error: Error) => void; @@ -286,6 +309,8 @@ export class RdpFileTransferProvider { * slow-but-progressing transfer keeps resetting it, so it is never killed. */ private static readonly UPLOAD_INACTIVITY_TIMEOUT_MS = 60 * 1000; + /** Timeout for storage backend write handle initialization (30 seconds). */ + private static readonly WRITE_HANDLE_INIT_TIMEOUT_MS = 30 * 1000; /** Maximum recursion depth when traversing dropped directories. */ private static readonly MAX_DIRECTORY_DEPTH = 32; /** Maximum total entries (files + directories) collected from a single drop. */ @@ -295,9 +320,12 @@ export class RdpFileTransferProvider { private readonly chunkSize: number; private readonly onUploadStarted?: () => void; private readonly onUploadFinished?: () => void; + private readonly storagePreference: StorageBackendPreference; // eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly eventHandlers: Map>> = new Map(); + private storageBackend?: FileStorageBackend; + private storageBackendReady?: Promise; private activeDownloads: Map = new Map(); private uploadState?: UploadState; // Upload paste-window watchdog. Armed when we advertise an upload, disarmed on the @@ -333,6 +361,29 @@ export class RdpFileTransferProvider { this.chunkSize = options?.chunkSize ?? 65536; // Default: 64KB this.onUploadStarted = options?.onUploadStarted; this.onUploadFinished = options?.onUploadFinished; + + const sb = options?.storageBackend; + if (typeof sb === 'object' && sb !== null) { + if ( + typeof (sb as FileStorageBackend).createWriteHandle !== 'function' || + typeof (sb as FileStorageBackend).dispose !== 'function' + ) { + throw new Error( + "storageBackend: expected 'auto', 'blob', or a FileStorageBackend " + + 'with createWriteHandle() and dispose() methods', + ); + } + this.storageBackend = sb; + this.storagePreference = 'auto'; // unused when backend is pre-set + } else { + const pref = sb ?? 'auto'; + if (pref !== 'auto' && pref !== 'blob') { + throw new Error( + `storageBackend: invalid preference '${pref}', expected 'auto', 'blob', or a FileStorageBackend instance`, + ); + } + this.storagePreference = pref; + } } /** @@ -350,6 +401,37 @@ export class RdpFileTransferProvider { return this.session; } + /** + * Lazily initialize and return the storage backend. + * + * Detection is performed once and cached. Concurrent callers share + * the same initialization promise so the probe only runs once. + * If detection fails the cached promise is cleared so subsequent + * downloads can retry. + */ + private async ensureStorageBackend(): Promise { + if (this.storageBackend) { + return this.storageBackend; + } + + if (!this.storageBackendReady) { + this.storageBackendReady = detectStorageBackend(this.storagePreference) + .then((backend) => { + this.storageBackend = backend; + console.debug(`File transfer storage: ${backend.name}`); + return backend; + }) + .catch((error: unknown) => { + // Clear the cached promise so the next download retries + // detection instead of hitting the same failure. + this.storageBackendReady = undefined; + throw error; + }); + } + + return this.storageBackendReady; + } + // --- Extension-based session method wrappers --- // These replace direct session.requestFileContents() etc. calls // with invokeExtension() to keep the Session interface protocol-agnostic. @@ -495,7 +577,6 @@ export class RdpFileTransferProvider { fileIndex, streamId, clipDataId, - chunks: [], bytesReceived: 0, resolve, reject, @@ -587,7 +668,7 @@ export class RdpFileTransferProvider { // Execute with concurrency limit. // Each task promise catches its own errors so that Promise.race/Promise.all - // never reject — errors are collected in the `errors` array above. + // never reject - errors are collected in the `errors` array above. const executing: Array> = []; for (const task of downloadTasks) { const promise = task().finally(() => { @@ -1198,7 +1279,7 @@ export class RdpFileTransferProvider { // Cancel active downloads (lock cleanup is handled by the Rust layer) for (const state of this.activeDownloads.values()) { - state.chunks = []; + void this.abortWriteHandle(state); state.reject(new Error('RdpFileTransferProvider disposed')); } this.activeDownloads.clear(); @@ -1215,6 +1296,16 @@ export class RdpFileTransferProvider { this.availableFiles = []; this.clipDataId = undefined; + // Dispose the storage backend (deletes OPFS session directory, etc.). + // Fire-and-forget -- dispose() is synchronous per the FileTransferProvider + // interface, but backend cleanup is async. This is acceptable because + // the session is terminating and the OPFS data is expendable. + if (this.storageBackend) { + void this.storageBackend.dispose(); + this.storageBackend = undefined; + this.storageBackendReady = undefined; + } + // Clear event handlers this.eventHandlers.clear(); } @@ -1333,7 +1424,9 @@ export class RdpFileTransferProvider { const fileHandle = files[request.index]; const dropped = droppedFiles[request.index]; if (dropped === undefined) { - console.error(`File index ${request.index} out of range`); + console.error( + `File index ${request.index} out of range (stream ${request.streamId}, valid: 0..${droppedFiles.length - 1})`, + ); this.sendSubmitFileContents(request.streamId, true, new Uint8Array()); return; } @@ -1548,7 +1641,7 @@ export class RdpFileTransferProvider { if (response.isError) { this.activeDownloads.delete(response.streamId); - state.chunks = []; + void this.abortWriteHandle(state); const err: FileTransferError = { message: 'Remote failed to provide file contents', transferId: state.streamId, @@ -1566,7 +1659,7 @@ export class RdpFileTransferProvider { // Validate response data is valid before creating DataView if (response.data.length < 8) { this.activeDownloads.delete(response.streamId); - state.chunks = []; + void this.abortWriteHandle(state); const err: FileTransferError = { message: 'Invalid SIZE response: expected 8 bytes for file size', transferId: state.streamId, @@ -1585,7 +1678,7 @@ export class RdpFileTransferProvider { // Validate file size doesn't exceed browser memory limits if (size > RdpFileTransferProvider.MAX_FILE_SIZE) { this.activeDownloads.delete(response.streamId); - state.chunks = []; + void this.abortWriteHandle(state); const err: FileTransferError = { message: `File size ${(size / (1024 * 1024 * 1024)).toFixed(2)}GB exceeds maximum download limit of 2GB`, transferId: state.streamId, @@ -1603,40 +1696,160 @@ export class RdpFileTransferProvider { // Handle empty files if (size === 0) { this.activeDownloads.delete(response.streamId); + void this.abortWriteHandle(state); const blob = new Blob([]); this.emit('download-complete', state.fileInfo, blob, state.fileIndex, state.streamId); state.resolve(blob); return; } - // Request data in chunks - this.requestNextChunk(state); + // Initialize the storage write handle now that we know the file + // size, then request the first data chunk. + this.initWriteHandleAndRequestFirstChunk(state); } else { - // This is a DATA response. - // TODO: chunks accumulate in memory until the download completes and a - // Blob is created. For a 2 GB file this means ~4 GB peak RAM (chunks + - // final Blob). Consider incremental Blob construction or the File System - // Access API (WritableStream) to reduce peak memory in a future milestone. - state.chunks.push(response.data); - state.bytesReceived += response.data.length; - - // Validate that received data doesn't grossly exceed expected size - if (state.bytesReceived > state.expectedSize * 2) { - this.activeDownloads.delete(response.streamId); - state.chunks = []; + // This is a DATA response -- write the chunk to the storage backend. + void this.handleDataChunk(state, response.data); + } + } + + /** + * Create a write handle for the given transfer and request the first + * data chunk. Runs asynchronously because backend initialization + * (especially OPFS) is async. + * + * The init promise is stored on `state.writeHandleReady` so that + * DATA responses arriving before the handle is ready can await it. + */ + private initWriteHandleAndRequestFirstChunk(state: TransferState): void { + // The init promise always resolves (never rejects) so that awaiting + // callers in handleDataChunk do not need individual error handling. + // Failures are signaled by leaving writeHandle undefined; the + // !state.writeHandle guard after the await detects this. + state.writeHandleReady = (async () => { + try { + const initPromise = (async () => { + const backend = await this.ensureStorageBackend(); + return backend.createWriteHandle(state.fileInfo.name, state.expectedSize ?? 0); + })(); + + const timeout = RdpFileTransferProvider.WRITE_HANDLE_INIT_TIMEOUT_MS; + const handle = await Promise.race([ + initPromise, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error(`Storage init timed out after ${timeout / 1000}s`)), timeout), + ), + ]); + + // Provider may have been disposed while we were awaiting. + // Abort the newly created handle to avoid orphaned OPFS files. + if (this.disposed || !this.activeDownloads.has(state.streamId)) { + try { + await handle.abort(); + } catch { + // Best-effort cleanup. + } + return; + } + + state.writeHandle = handle; + } catch (error) { + this.activeDownloads.delete(state.streamId); const err: FileTransferError = { - message: `Received ${state.bytesReceived} bytes but expected ${state.expectedSize} — aborting`, + message: 'Failed to initialize storage for download', transferId: state.streamId, fileIndex: state.fileIndex, fileName: state.fileInfo.name, direction: 'download', + cause: error, }; this.emit('error', err); - state.reject(new Error(err.message)); + state.reject(new Error(err.message, { cause: error })); + // Do not rethrow: the promise must resolve (not reject) so + // that awaiting callers in handleDataChunk can detect the + // failure via the !state.writeHandle guard without needing + // per-caller error handling. return; } - // Emit progress (clamp percentage to 100% in case server sends slightly more than expected) + this.requestNextChunk(state); + })(); + } + + /** + * Write a data chunk to the storage backend and advance the download. + * + * If the write handle is not yet ready (DATA response arrived before + * backend init completed), this method awaits `state.writeHandleReady` + * to preserve chunk ordering -- each concurrent caller awaits the same + * promise in sequence. + */ + private async handleDataChunk(state: TransferState, data: Uint8Array): Promise { + if (!state.writeHandle) { + if (!state.writeHandleReady || this.disposed || !this.activeDownloads.has(state.streamId)) { + // No init in progress or download already cancelled. + return; + } + // writeHandleReady always resolves (never rejects); init + // failures leave writeHandle undefined, caught by the + // guard below. + await state.writeHandleReady; + + // Re-check: the download may have been cancelled or disposed + // while we were waiting for the write handle. + if (this.disposed || !this.activeDownloads.has(state.streamId)) { + return; + } + } + + // Guard defensively: writeHandle may still be undefined if init + // failed or dispose() cleared it during the await above. + const writeHandle = state.writeHandle; + if (!writeHandle) { + return; + } + + try { + await writeHandle.write(data); + } catch (error) { + this.activeDownloads.delete(state.streamId); + void this.abortWriteHandle(state); + const isQuota = error instanceof DOMException && error.name === 'QuotaExceededError'; + const message = isQuota + ? `Storage quota exceeded while downloading "${state.fileInfo.name}"` + : 'Failed to write download chunk to storage'; + const err: FileTransferError = { + message, + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + cause: error, + }; + this.emit('error', err); + state.reject(new Error(err.message, { cause: error })); + return; + } + + state.bytesReceived += data.length; + + // Validate that received data doesn't grossly exceed expected size + if (state.expectedSize !== undefined && state.bytesReceived > state.expectedSize * 2) { + this.activeDownloads.delete(state.streamId); + void this.abortWriteHandle(state); + const err: FileTransferError = { + message: `Received ${state.bytesReceived} bytes but expected ${state.expectedSize} - aborting`, + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + }; + this.emit('error', err); + state.reject(new Error(err.message)); + return; + } + + // Emit progress (clamp percentage to 100% in case server sends slightly more than expected) + if (state.expectedSize !== undefined) { const progress: TransferProgress = { transferId: state.streamId, fileIndex: state.fileIndex, @@ -1646,17 +1859,43 @@ export class RdpFileTransferProvider { percentage: Math.min((state.bytesReceived / state.expectedSize) * 100, 100), }; this.emit('download-progress', progress); + } - // Check if download complete - if (state.bytesReceived >= state.expectedSize) { - this.activeDownloads.delete(response.streamId); - const blob = new Blob(state.chunks as BlobPart[]); + // Check if download complete + if (state.expectedSize !== undefined && state.bytesReceived >= state.expectedSize) { + this.activeDownloads.delete(state.streamId); + try { + const blob = await writeHandle.finalize(); this.emit('download-complete', state.fileInfo, blob, state.fileIndex, state.streamId); state.resolve(blob); - } else { - // Request next chunk - this.requestNextChunk(state); + } catch (error) { + void this.abortWriteHandle(state); + const err: FileTransferError = { + message: 'Failed to finalize downloaded file', + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + cause: error, + }; + this.emit('error', err); + state.reject(new Error(err.message, { cause: error })); + } + } else { + // Request next chunk + this.requestNextChunk(state); + } + } + + /** Abort and clean up a write handle, ignoring errors. */ + private async abortWriteHandle(state: TransferState): Promise { + if (state.writeHandle) { + try { + await state.writeHandle.abort(); + } catch { + // Best-effort cleanup. } + state.writeHandle = undefined; } } @@ -1699,7 +1938,7 @@ export class RdpFileTransferProvider { for (const [streamId, state] of this.activeDownloads) { if (state.clipDataId !== undefined && expiredLockSet.has(state.clipDataId)) { this.activeDownloads.delete(streamId); - state.chunks = []; + void this.abortWriteHandle(state); // Build user-friendly error message with timeout info and remediation const errorMessage = @@ -1743,7 +1982,7 @@ export class RdpFileTransferProvider { ); } catch (error) { this.activeDownloads.delete(state.streamId); - state.chunks = []; + void this.abortWriteHandle(state); const err: FileTransferError = { message: 'Failed to request file chunk', transferId: state.streamId, @@ -1773,6 +2012,6 @@ export class RdpFileTransferProvider { } } // Should never happen: more active downloads than the counter can skip - throw new Error('unable to generate unique stream ID'); + throw new Error('Unable to generate unique stream ID'); } } diff --git a/web-client/iron-remote-desktop-rdp/src/main.ts b/web-client/iron-remote-desktop-rdp/src/main.ts index 761f151c82..ccc39a83ac 100644 --- a/web-client/iron-remote-desktop-rdp/src/main.ts +++ b/web-client/iron-remote-desktop-rdp/src/main.ts @@ -60,6 +60,15 @@ export type { export type { FileInfo, FileContentsRequest, FileContentsResponse } from './FileTransfer'; export { FileContentsFlags } from './FileContentsFlags'; +// --- Storage backends --- +// Re-export for consumers who want to configure the storageBackend +// option on RdpFileTransferProviderOptions, implement a custom backend, +// or construct a specific backend instance directly. +export type { FileStorageBackend, FileWriteHandle, StorageBackendPreference } from './storage'; +export { BlobStorageBackend } from './storage'; +export { OpfsStorageBackend } from './storage'; +export { detectStorageBackend } from './storage'; + // Re-export extension factories for advanced consumers who want to // register callbacks or invoke file transfer operations directly. export { diff --git a/web-client/iron-remote-desktop-rdp/src/storage/BlobStorageBackend.ts b/web-client/iron-remote-desktop-rdp/src/storage/BlobStorageBackend.ts new file mode 100644 index 0000000000..c7ac3f8478 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/BlobStorageBackend.ts @@ -0,0 +1,69 @@ +import type { FileStorageBackend, FileWriteHandle } from './FileStorageBackend'; + +/** + * Write handle that accumulates chunks in an in-memory array and assembles + * a {@link Blob} on {@link finalize}. + * + * Simple and universally supported, but peak RAM is approximately 2x the + * file size (the chunk array plus the final Blob). See + * {@link OpfsStorageBackend} for a streaming alternative. + */ +class BlobWriteHandle implements FileWriteHandle { + private chunks: Uint8Array[] = []; + private _bytesWritten = 0; + private finalized = false; + + get bytesWritten(): number { + return this._bytesWritten; + } + + async write(chunk: Uint8Array): Promise { + if (this.finalized) { + throw new Error('BlobWriteHandle: write after finalize/abort'); + } + this.chunks.push(chunk); + this._bytesWritten += chunk.length; + } + + async finalize(): Promise { + if (this.finalized) { + throw new Error('BlobWriteHandle: already finalized or aborted'); + } + this.finalized = true; + const blob = new Blob(this.chunks); + this.chunks = []; + return blob; + } + + async abort(): Promise { + if (this.finalized) { + return; + } + this.finalized = true; + this.chunks = []; + } +} + +/** + * In-memory Blob storage backend. + * + * Downloads are buffered as {@link Uint8Array} chunks in a plain array and + * assembled into a single {@link Blob} when the transfer completes. This + * is the universal fallback that works in every browser context. + * + * **Trade-offs:** + * - Peak RAM ~2x file size (chunk array + final Blob). + * - No persistent storage; data is lost on page unload. + * - No setup cost; works even in non-secure contexts and private browsing. + */ +export class BlobStorageBackend implements FileStorageBackend { + readonly name = 'blob'; + + async createWriteHandle(_fileName: string, _expectedSize: number): Promise { + return new BlobWriteHandle(); + } + + async dispose(): Promise { + // Nothing persistent to clean up. + } +} diff --git a/web-client/iron-remote-desktop-rdp/src/storage/FileStorageBackend.ts b/web-client/iron-remote-desktop-rdp/src/storage/FileStorageBackend.ts new file mode 100644 index 0000000000..8660d1e240 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/FileStorageBackend.ts @@ -0,0 +1,85 @@ +/** + * A write handle for streaming file data to a storage backend. + * + * Created once per download via {@link FileStorageBackend.createWriteHandle}. + * Chunks are appended via {@link write}, and on success {@link finalize} + * returns the assembled data as a {@link Blob} (or {@link File}, which + * extends Blob). On failure or cancellation, {@link abort} releases all + * resources held by the handle. + * + * Implementations may buffer in memory (Blob backend), stream to the + * Origin Private File System (OPFS backend), or stream to a user-chosen + * location (future FSAPI backend). + */ +export interface FileWriteHandle { + /** Append a chunk of data. + * + * Backends may buffer the chunk in memory or flush it to persistent + * storage immediately. The returned promise resolves once the chunk + * has been accepted (not necessarily persisted). */ + write(chunk: Uint8Array): Promise; + + /** + * Finalize the file and return the result as a Blob. + * + * For in-memory backends this assembles a Blob from buffered chunks. + * For persistent backends (e.g. OPFS) this closes the writable stream + * and returns a {@link File} (which extends Blob) backed by the on-disk + * data, keeping peak RAM close to zero. + * + * After calling finalize the handle must not be reused. + */ + finalize(): Promise; + + /** + * Discard all written data and release resources. + * + * Safe to call multiple times. After abort the handle must not be + * reused. + */ + abort(): Promise; + + /** Number of bytes successfully written so far. */ + readonly bytesWritten: number; +} + +/** + * Pluggable storage backend for file transfer downloads. + * + * The backend determines *where* incoming file chunks are buffered during + * a download. Protocol-specific file transfer providers delegate all + * storage concerns to the active backend, keeping download orchestration + * logic storage-agnostic. + * + * Three backends are planned: + * + * | Backend | Buffering | Peak RAM | Browser support | + * |---------|--------------------|---------------|---------------------------| + * | Blob | In-memory array | ~2x file size | Universal | + * | OPFS | Origin Private FS | ~chunk size | Baseline (September 2025) | + * | FSAPI | User-chosen file | ~chunk size | Chromium-only (future) | + */ +export interface FileStorageBackend { + /** Human-readable backend name, used in log messages. */ + readonly name: string; + + /** + * Create a write handle for a new download. + * + * @param fileName - Sanitized file basename (used for the temp file + * name in persistent backends). + * @param expectedSize - Expected total size in bytes. Backends may use + * this for pre-allocation or quota checks. A value + * of 0 means the size is unknown or the file is + * empty. + */ + createWriteHandle(fileName: string, expectedSize: number): Promise; + + /** + * Release all backend resources. + * + * For persistent backends this deletes the session directory and any + * temp files. Safe to call multiple times. + */ + dispose(): Promise; +} diff --git a/web-client/iron-remote-desktop-rdp/src/storage/OpfsStorageBackend.ts b/web-client/iron-remote-desktop-rdp/src/storage/OpfsStorageBackend.ts new file mode 100644 index 0000000000..08bc43b357 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/OpfsStorageBackend.ts @@ -0,0 +1,301 @@ +import type { FileStorageBackend, FileWriteHandle } from './FileStorageBackend'; + +/** + * Write handle that streams chunks to a file in the Origin Private File + * System via {@link FileSystemWritableFileStream}. + * + * Each chunk is flushed to disk immediately, so peak RAM stays close to + * the chunk size regardless of total file size. On {@link finalize} the + * stream is closed and a lazy {@link File} reference (which extends + * {@link Blob}) is returned -- the browser memory-maps reads from OPFS + * rather than loading the entire file into RAM. + */ +class OpfsWriteHandle implements FileWriteHandle { + private writable: FileSystemWritableFileStream | undefined; + private _bytesWritten = 0; + private finalized = false; + + constructor( + private readonly fileHandle: FileSystemFileHandle, + private readonly sessionDir: FileSystemDirectoryHandle, + private readonly entryName: string, + writable: FileSystemWritableFileStream, + ) { + this.writable = writable; + } + + get bytesWritten(): number { + return this._bytesWritten; + } + + async write(chunk: Uint8Array): Promise { + if (this.finalized || !this.writable) { + throw new Error('OpfsWriteHandle: write after finalize/abort'); + } + await this.writable.write(chunk); + this._bytesWritten += chunk.length; + } + + async finalize(): Promise { + if (this.finalized) { + throw new Error('OpfsWriteHandle: already finalized or aborted'); + } + this.finalized = true; + + if (this.writable) { + await this.writable.close(); + this.writable = undefined; + } + + // getFile() returns a File (extends Blob) backed by OPFS storage. + // The browser lazily reads from disk -- the file data is NOT copied + // into RAM here. + return this.fileHandle.getFile(); + } + + async abort(): Promise { + if (this.finalized) { + return; + } + this.finalized = true; + + if (this.writable) { + try { + await this.writable.abort(); + } catch { + // Writable may already be closed or errored; ignore. + } + this.writable = undefined; + } + + // Remove the temp file so it does not consume quota. + try { + await this.sessionDir.removeEntry(this.entryName); + } catch { + // Entry may already be gone (e.g., session dir was deleted). + } + } +} + +/** + * Storage backend that streams download chunks to the Origin Private File + * System (OPFS). + * + * OPFS is a browser-provided, origin-scoped file system that requires no + * user permission prompts and is available on the main thread (async + * only). This backend requires the {@link FileSystemWritableFileStream} + * API, which reached Baseline across all major browsers in September 2025 + * (Chrome 86+, Firefox 111+, Safari 17.2+, Edge 86+). Older browsers + * are detected automatically via {@link OpfsStorageBackend.probe} and + * fall back to the Blob backend. + * + * **How it works:** + * 1. On construction, a per-session subdirectory is created under + * `ironrdp-transfers/` in the OPFS root. + * 2. Each download opens a {@link FileSystemWritableFileStream} inside + * that directory and flushes chunks to disk as they arrive. + * 3. On completion, the stream is closed and a lazy {@link File} handle is + * returned. The File extends Blob, so existing consumers that expect + * a Blob work without changes. + * 4. On dispose, the entire session directory is deleted. + * + * **Trade-offs vs Blob backend:** + * - Peak RAM drops from ~2x file size to ~chunk size (typically 64 KB). + * - Moderate write latency per chunk (async disk I/O), but the download + * is already async and network-bound. + * - Storage is subject to the origin's quota (typically 60% of disk). + * - May be unavailable in some private browsing modes. + * + * **Construction:** Use the static {@link OpfsStorageBackend.create} + * factory method. The constructor is private because initialization + * requires async OPFS directory setup. + */ +export class OpfsStorageBackend implements FileStorageBackend { + readonly name = 'opfs'; + + /** Sequence counter for generating unique temp file names. */ + private sequence = 0; + + private constructor( + private readonly opfsRoot: FileSystemDirectoryHandle, + private sessionDir: FileSystemDirectoryHandle | undefined, + private readonly sessionId: string, + ) {} + + /** + * Create an OPFS backend, including the per-session directory. + * + * Call {@link probe} first to verify OPFS is available before + * constructing -- this factory assumes OPFS works. + */ + static async create(opfsRoot: FileSystemDirectoryHandle, sessionId?: string): Promise { + const id = sessionId ?? `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const transfersDir = await opfsRoot.getDirectoryHandle('ironrdp-transfers', { create: true }); + const sessionDir = await transfersDir.getDirectoryHandle(id, { create: true }); + + // Best-effort cleanup of orphaned session directories from previous + // sessions that did not call dispose() (e.g., tab crash, browser + // force-quit). Runs asynchronously and never blocks creation. + void OpfsStorageBackend.cleanupStale(transfersDir, id); + + return new OpfsStorageBackend(opfsRoot, sessionDir, id); + } + + /** Maximum age (in milliseconds) before a session directory is considered stale. */ + private static readonly STALE_SESSION_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours + + /** + * Remove session directories older than {@link STALE_SESSION_THRESHOLD_MS}. + * + * Session IDs generated by {@link create} embed a timestamp in the + * format `s-{Date.now()}-{random}`. This method parses that timestamp + * to determine age. Directories with unparsable names or those + * belonging to the current session are skipped. + */ + private static async cleanupStale( + transfersDir: FileSystemDirectoryHandle, + currentSessionId: string, + ): Promise { + const now = Date.now(); + try { + for await (const name of transfersDir.keys()) { + if (name === currentSessionId) { + continue; + } + // Parse timestamp from the session ID format: s-{timestamp}-{random}. + const match = /^s-(\d+)-/.exec(name); + if (!match) { + continue; + } + const timestamp = Number(match[1]); + if (now - timestamp > OpfsStorageBackend.STALE_SESSION_THRESHOLD_MS) { + try { + await transfersDir.removeEntry(name, { recursive: true }); + } catch { + // Ignore per-entry errors (may be in use by another tab). + } + } + } + } catch { + // The transfers directory may have been removed or is inaccessible. + } + } + + /** + * Probe whether OPFS is usable in the current context. + * + * Performs a full round-trip: creates a temp file, opens a writable, + * closes it, and deletes it. This catches environments where the API + * exists but throws at runtime (e.g., some private browsing modes). + */ + static async probe(opfsRoot: FileSystemDirectoryHandle): Promise { + try { + const handle = await opfsRoot.getFileHandle('.ironrdp-opfs-probe', { create: true }); + const writable = await handle.createWritable(); + await writable.close(); + await opfsRoot.removeEntry('.ironrdp-opfs-probe'); + return true; + } catch { + return false; + } + } + + async createWriteHandle(fileName: string, _expectedSize: number): Promise { + if (!this.sessionDir) { + throw new Error('OpfsStorageBackend: backend has been disposed'); + } + + // Use a sequence number + sanitized name to avoid collisions when + // the same file name is downloaded multiple times in one session. + const seq = this.sequence++; + const entryName = `${seq}-${sanitizeOpfsName(fileName)}`; + + const fileHandle = await this.sessionDir.getFileHandle(entryName, { create: true }); + const writable = await fileHandle.createWritable(); + + return new OpfsWriteHandle(fileHandle, this.sessionDir, entryName, writable); + } + + async dispose(): Promise { + if (!this.sessionDir) { + return; + } + + const sessionDir = this.sessionDir; + this.sessionDir = undefined; + + try { + const transfersDir = await this.opfsRoot.getDirectoryHandle('ironrdp-transfers'); + await transfersDir.removeEntry(this.sessionId, { recursive: true }); + + // Clean up the parent directory if it is now empty. + let hasEntries = false; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of transfersDir.values()) { + hasEntries = true; + break; + } + if (!hasEntries) { + await this.opfsRoot.removeEntry('ironrdp-transfers'); + } + } catch (error) { + console.debug('OPFS session directory removal failed, falling back to per-file cleanup:', error); + // The directory may already be gone if another tab cleaned up, + // or the OPFS was cleared externally. Fall back to deleting + // individual files from the session directory handle. + try { + for await (const name of sessionDir.keys()) { + try { + await sessionDir.removeEntry(name); + } catch { + // Ignore per-file errors. + } + } + } catch { + // Session dir handle may be stale; nothing more to do. + } + } + } +} + +/** + * Sanitize a file name for use as an OPFS entry name. + * + * OPFS entry names must not contain `/` or `\`, and must not be `.` or + * `..`. We strip control characters, replace separators with + * underscores, and strip leading dots. + */ +function sanitizeOpfsName(name: string): string { + // Strip ASCII control characters (U+0000-U+001F) that could cause + // inconsistent behavior across OPFS implementations or confuse logs. + // eslint-disable-next-line no-control-regex + let safe = name.replace(/[\u0000-\u001f]/g, ''); + + safe = safe.replace(/[/\\]/g, '_'); + + // Strip leading dots to avoid `.` / `..` collisions. + safe = safe.replace(/^\.+/, ''); + + // Ensure we always have a non-empty name. + if (safe.length === 0) { + safe = 'unnamed'; + } + + // OPFS entry names are typically limited to 255 bytes. Truncate by + // UTF-8 byte length (not JS char count) to leave room for the + // sequence prefix added by createWriteHandle. Non-ASCII characters + // can be 2-4 bytes each, so a char-based limit could still exceed + // the byte budget. + const encoder = new TextEncoder(); + if (encoder.encode(safe).byteLength > 200) { + while (encoder.encode(safe).byteLength > 200) { + safe = safe.slice(0, -1); + } + // Ensure truncation did not leave us empty. + if (safe.length === 0) { + safe = 'unnamed'; + } + } + + return safe; +} diff --git a/web-client/iron-remote-desktop-rdp/src/storage/detect.ts b/web-client/iron-remote-desktop-rdp/src/storage/detect.ts new file mode 100644 index 0000000000..0bfdf94236 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/detect.ts @@ -0,0 +1,62 @@ +import type { FileStorageBackend } from './FileStorageBackend'; +import { BlobStorageBackend } from './BlobStorageBackend'; +import { OpfsStorageBackend } from './OpfsStorageBackend'; + +/** + * Storage backend preference for downloads. + * + * - `'auto'` - detect the best available backend (OPFS with Blob + * fallback). OPFS reduces peak download RAM from ~2x file size to + * ~chunk size. + * - `'blob'` - force in-memory Blob storage regardless of OPFS + * availability. + */ +export type StorageBackendPreference = 'auto' | 'blob'; + +/** + * Detect the best available storage backend for the current browser + * context. + * + * When `preference` is `'auto'` (default), the function probes for OPFS + * support with a full round-trip smoke test. If OPFS is available, an + * {@link OpfsStorageBackend} is returned; otherwise a + * {@link BlobStorageBackend} is used as the universal fallback. + * + * When `preference` is `'blob'`, OPFS detection is skipped entirely and + * the Blob backend is returned immediately. + * + * @param preference - `'auto'` to detect, `'blob'` to force in-memory. + * @param sessionId - Optional session identifier used as the OPFS + * subdirectory name. When omitted a unique ID is + * generated from the current timestamp. + * @returns The selected backend, ready to use. + */ +export async function detectStorageBackend( + preference: StorageBackendPreference = 'auto', + sessionId?: string, +): Promise { + if (preference === 'blob') { + return new BlobStorageBackend(); + } + + // Attempt OPFS detection. + if (typeof globalThis.navigator?.storage?.getDirectory === 'function') { + try { + const opfsRoot = await navigator.storage.getDirectory(); + + if (await OpfsStorageBackend.probe(opfsRoot)) { + return OpfsStorageBackend.create(opfsRoot, sessionId); + } + + // Probe failed: the OPFS API exists but is not functional + // (e.g., createWritable() throws in some browser modes). + console.debug('OPFS probe failed (createWritable not functional), falling back to blob storage'); + } catch (error) { + // getDirectory() itself threw (e.g., SecurityError in some + // private browsing modes). Fall through to Blob. + console.debug('OPFS unavailable, falling back to blob storage:', error); + } + } + + return new BlobStorageBackend(); +} diff --git a/web-client/iron-remote-desktop-rdp/src/storage/index.ts b/web-client/iron-remote-desktop-rdp/src/storage/index.ts new file mode 100644 index 0000000000..6d0332a846 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/index.ts @@ -0,0 +1,5 @@ +export type { FileStorageBackend, FileWriteHandle } from './FileStorageBackend'; +export { BlobStorageBackend } from './BlobStorageBackend'; +export { OpfsStorageBackend } from './OpfsStorageBackend'; +export { detectStorageBackend } from './detect'; +export type { StorageBackendPreference } from './detect'; diff --git a/web-client/iron-remote-desktop-rdp/src/storage/storage.test.ts b/web-client/iron-remote-desktop-rdp/src/storage/storage.test.ts new file mode 100644 index 0000000000..f6e885cf90 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/storage.test.ts @@ -0,0 +1,620 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BlobStorageBackend } from './BlobStorageBackend'; +import { OpfsStorageBackend } from './OpfsStorageBackend'; +import { detectStorageBackend } from './detect'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function chunk(bytes: number[]): Uint8Array { + return new Uint8Array(bytes); +} + +async function blobToBytes(blob: Blob): Promise { + // jsdom Blob.arrayBuffer() may not be available or may behave + // inconsistently. Use FileReader which jsdom supports reliably. + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); +} + +// --------------------------------------------------------------------------- +// Minimal in-memory mock of the OPFS directory/file handle API. +// Shared across OpfsStorageBackend and detectStorageBackend test suites. +// --------------------------------------------------------------------------- + +interface MockEntry { + kind: 'file' | 'directory'; + name: string; + children?: Map; + content?: Uint8Array[]; + writable?: MockWritable; +} + +class MockWritable { + closed = false; + aborted = false; + private target: MockEntry; + + constructor(target: MockEntry) { + this.target = target; + // Reset content on each new writable (mirrors real createWritable) + this.target.content = []; + } + + async write(data: Uint8Array): Promise { + if (this.closed || this.aborted) throw new Error('stream closed'); + this.target.content!.push(new Uint8Array(data)); + } + + async close(): Promise { + this.closed = true; + } + + async abort(): Promise { + this.aborted = true; + } +} + +function createMockFileHandle(entry: MockEntry): FileSystemFileHandle { + return { + kind: 'file' as const, + name: entry.name, + isSameEntry: vi.fn(), + async getFile() { + const parts = entry.content ?? []; + return new Blob(parts); + }, + async createWritable() { + const w = new MockWritable(entry); + entry.writable = w; + return w as unknown as FileSystemWritableFileStream; + }, + async createSyncAccessHandle() { + throw new Error('not implemented'); + }, + } as unknown as FileSystemFileHandle; +} + +function createMockDirectoryHandle(name: string, children?: Map): FileSystemDirectoryHandle { + const entries: Map = children ?? new Map(); + + function makeValuesIterator(): AsyncIterableIterator { + const values = [...entries.values()]; + let index = 0; + return { + [Symbol.asyncIterator]() { + return this; + }, + async next() { + if (index < values.length) { + return { value: values[index++] as unknown as FileSystemHandle, done: false as const }; + } + return { value: undefined, done: true as const }; + }, + }; + } + + function makeKeysIterator(): AsyncIterableIterator { + const keys = [...entries.keys()]; + let index = 0; + return { + [Symbol.asyncIterator]() { + return this; + }, + async next() { + if (index < keys.length) { + return { value: keys[index++], done: false as const }; + } + return { value: undefined, done: true as const }; + }, + }; + } + + return { + kind: 'directory' as const, + name, + isSameEntry: vi.fn(), + async getFileHandle(fileName: string, options?: FileSystemGetFileOptions) { + let entry = entries.get(fileName); + if (entry === undefined && options?.create === true) { + entry = { kind: 'file', name: fileName, content: [] }; + entries.set(fileName, entry); + } + if (entry === undefined) throw new DOMException('NotFoundError'); + return createMockFileHandle(entry); + }, + async getDirectoryHandle(dirName: string, options?: FileSystemGetDirectoryOptions) { + let entry = entries.get(dirName); + if (entry === undefined && options?.create === true) { + entry = { kind: 'directory', name: dirName, children: new Map() }; + entries.set(dirName, entry); + } + if (entry === undefined) throw new DOMException('NotFoundError'); + return createMockDirectoryHandle(dirName, entry.children); + }, + async removeEntry(entryName: string, _options?: FileSystemRemoveOptions) { + if (!entries.has(entryName)) throw new DOMException('NotFoundError'); + entries.delete(entryName); + }, + async resolve(_child: FileSystemHandle) { + return null; + }, + values: makeValuesIterator, + keys: makeKeysIterator, + entries() { + return makeValuesIterator() as unknown as AsyncIterableIterator<[string, FileSystemHandle]>; + }, + [Symbol.asyncIterator]() { + return makeValuesIterator() as unknown as AsyncIterableIterator<[string, FileSystemHandle]>; + }, + } as unknown as FileSystemDirectoryHandle; +} + +// --------------------------------------------------------------------------- +// BlobStorageBackend +// --------------------------------------------------------------------------- + +describe('BlobStorageBackend', () => { + let backend: BlobStorageBackend; + + beforeEach(() => { + backend = new BlobStorageBackend(); + }); + + it('has name "blob"', () => { + expect(backend.name).toBe('blob'); + }); + + it('write then finalize produces correct Blob', async () => { + const handle = await backend.createWriteHandle('test.bin', 6); + + await handle.write(chunk([1, 2, 3])); + expect(handle.bytesWritten).toBe(3); + + await handle.write(chunk([4, 5, 6])); + expect(handle.bytesWritten).toBe(6); + + const blob = await handle.finalize(); + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBe(6); + + const data = await blobToBytes(blob); + expect(data).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6])); + }); + + it('finalize on empty handle returns empty Blob', async () => { + const handle = await backend.createWriteHandle('empty.bin', 0); + const blob = await handle.finalize(); + expect(blob.size).toBe(0); + expect(handle.bytesWritten).toBe(0); + }); + + it('abort clears state and prevents reuse', async () => { + const handle = await backend.createWriteHandle('test.bin', 3); + await handle.write(chunk([1, 2, 3])); + await handle.abort(); + + // Write after abort throws + await expect(handle.write(chunk([4]))).rejects.toThrow(/finalize|abort/); + // Finalize after abort throws + await expect(handle.finalize()).rejects.toThrow(/finalize|abort/); + }); + + it('double abort is safe', async () => { + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.abort(); + await expect(handle.abort()).resolves.toBeUndefined(); + }); + + it('write after finalize throws', async () => { + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.finalize(); + await expect(handle.write(chunk([1]))).rejects.toThrow(/finalize|abort/); + }); + + it('double finalize throws', async () => { + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.finalize(); + await expect(handle.finalize()).rejects.toThrow(/finalize|abort/); + }); + + it('dispose is a no-op', async () => { + await expect(backend.dispose()).resolves.toBeUndefined(); + }); + + it('multiple concurrent handles are independent', async () => { + const h1 = await backend.createWriteHandle('a.bin', 2); + const h2 = await backend.createWriteHandle('b.bin', 2); + + await h1.write(chunk([10, 20])); + await h2.write(chunk([30, 40])); + + const b1 = await h1.finalize(); + const b2 = await h2.finalize(); + + expect(await blobToBytes(b1)).toEqual(new Uint8Array([10, 20])); + expect(await blobToBytes(b2)).toEqual(new Uint8Array([30, 40])); + }); +}); + +// --------------------------------------------------------------------------- +// OpfsStorageBackend - mocked OPFS +// --------------------------------------------------------------------------- + +describe('OpfsStorageBackend', () => { + let mockOpfsRoot: FileSystemDirectoryHandle; + + beforeEach(() => { + mockOpfsRoot = createMockDirectoryHandle(''); + }); + + describe('probe', () => { + it('returns true when OPFS works', async () => { + expect(await OpfsStorageBackend.probe(mockOpfsRoot)).toBe(true); + }); + + it('returns false when createWritable throws', async () => { + const broken = { + ...mockOpfsRoot, + async getFileHandle() { + return { + async createWritable() { + throw new Error('SecurityError'); + }, + } as unknown as FileSystemFileHandle; + }, + } as unknown as FileSystemDirectoryHandle; + + expect(await OpfsStorageBackend.probe(broken)).toBe(false); + }); + }); + + describe('lifecycle', () => { + it('creates session directory on construction', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'test-session'); + expect(backend.name).toBe('opfs'); + + // Verify the session directory was created + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers'); + const sessionDir = await transfersDir.getDirectoryHandle('test-session'); + expect(sessionDir.name).toBe('test-session'); + + await backend.dispose(); + }); + + it('write/finalize produces a Blob', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-1'); + const handle = await backend.createWriteHandle('hello.bin', 4); + + await handle.write(chunk([10, 20])); + expect(handle.bytesWritten).toBe(2); + + await handle.write(chunk([30, 40])); + expect(handle.bytesWritten).toBe(4); + + const blob = await handle.finalize(); + expect(blob.size).toBe(4); + + const data = await blobToBytes(blob); + expect(data).toEqual(new Uint8Array([10, 20, 30, 40])); + + await backend.dispose(); + }); + + it('abort removes the temp file', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-2'); + const handle = await backend.createWriteHandle('abort-me.bin', 4); + await handle.write(chunk([1, 2, 3, 4])); + await handle.abort(); + + // Write after abort should throw + await expect(handle.write(chunk([5]))).rejects.toThrow(); + + await backend.dispose(); + }); + + it('double abort is safe', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-3'); + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.abort(); + await expect(handle.abort()).resolves.toBeUndefined(); + await backend.dispose(); + }); + + it('write after finalize throws', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-waf'); + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.finalize(); + await expect(handle.write(chunk([1]))).rejects.toThrow(/finalize|abort/); + await backend.dispose(); + }); + + it('double finalize throws', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-df'); + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.finalize(); + await expect(handle.finalize()).rejects.toThrow(/finalize|abort/); + await backend.dispose(); + }); + + it('dispose cleans up session directory', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-cleanup'); + await backend.createWriteHandle('file1.bin', 0); + await backend.dispose(); + + // When the session was the only child, dispose also removes the + // parent `ironrdp-transfers` directory. Either the parent or + // the session subdir being gone confirms cleanup succeeded. + let sessionDirExists = true; + try { + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers'); + await transfersDir.getDirectoryHandle('sess-cleanup'); + } catch { + sessionDirExists = false; + } + expect(sessionDirExists).toBe(false); + }); + + it('double dispose is safe', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-double'); + await backend.dispose(); + await expect(backend.dispose()).resolves.toBeUndefined(); + }); + + it('createWriteHandle after dispose throws', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-after'); + await backend.dispose(); + await expect(backend.createWriteHandle('nope.bin', 0)).rejects.toThrow(/disposed/); + }); + + it('sanitizes file names for OPFS entries', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-sanitize'); + + // Collect entry names created in the session directory. + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers'); + const sessionDir = await transfersDir.getDirectoryHandle('sess-sanitize'); + async function entryNames(): Promise { + const names: string[] = []; + for await (const name of sessionDir.keys()) names.push(name); + return names; + } + + // Path traversal: slashes become underscores, dots are interior + // (leading-dot stripping only applies after separator replacement). + const h1 = await backend.createWriteHandle('../../etc/passwd', 3); + await h1.write(chunk([1, 2, 3])); + await h1.finalize(); + expect(await entryNames()).toEqual(['0-_.._etc_passwd']); + + // Bare ".." becomes empty after stripping, falls back to "unnamed". + const h2 = await backend.createWriteHandle('..', 1); + await h2.write(chunk([1])); + await h2.finalize(); + expect(await entryNames()).toContain('1-unnamed'); + + // Leading dots stripped, rest preserved. + const h3 = await backend.createWriteHandle('.hidden', 1); + await h3.write(chunk([1])); + await h3.finalize(); + expect(await entryNames()).toContain('2-hidden'); + + // Control characters (null bytes, tabs, newlines) are stripped. + const h4 = await backend.createWriteHandle('foo\x00bar\tbaz\n.txt', 1); + await h4.write(chunk([1])); + await h4.finalize(); + expect(await entryNames()).toContain('3-foobarbaz.txt'); + + // Multi-byte UTF-8 names are truncated by byte length, not char count. + // Each emoji is 4 UTF-8 bytes; 51 emojis = 204 bytes > 200 byte limit. + const longEmoji = '\u{1F600}'.repeat(51); // 51 x 4 = 204 bytes + const h5 = await backend.createWriteHandle(longEmoji, 1); + await h5.write(chunk([1])); + await h5.finalize(); + const names = await entryNames(); + const emojiEntry = names.find((n) => n.startsWith('4-')); + expect(emojiEntry).toBeDefined(); + // Should be truncated to at most 200 UTF-8 bytes (50 emojis = 200 bytes). + const encoder = new TextEncoder(); + const sanitized = emojiEntry!.slice(2); // strip "4-" prefix + expect(encoder.encode(sanitized).byteLength).toBeLessThanOrEqual(200); + expect(encoder.encode(sanitized).byteLength).toBeGreaterThan(0); + + await backend.dispose(); + }); + + it('handles concurrent writes to different files', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-concurrent'); + + const h1 = await backend.createWriteHandle('a.bin', 2); + const h2 = await backend.createWriteHandle('b.bin', 2); + + await h1.write(chunk([1, 2])); + await h2.write(chunk([3, 4])); + + const b1 = await h1.finalize(); + const b2 = await h2.finalize(); + + expect(await blobToBytes(b1)).toEqual(new Uint8Array([1, 2])); + expect(await blobToBytes(b2)).toEqual(new Uint8Array([3, 4])); + + await backend.dispose(); + }); + + it('generates unique entry names for duplicate file names', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-dup'); + + // Two downloads of the same file name should not collide + const h1 = await backend.createWriteHandle('same.bin', 1); + const h2 = await backend.createWriteHandle('same.bin', 1); + + await h1.write(chunk([10])); + await h2.write(chunk([20])); + + const b1 = await h1.finalize(); + const b2 = await h2.finalize(); + + // They should be independent + expect(await blobToBytes(b1)).toEqual(new Uint8Array([10])); + expect(await blobToBytes(b2)).toEqual(new Uint8Array([20])); + + await backend.dispose(); + }); + }); + + describe('stale session cleanup', () => { + it('removes session directories older than 24 hours on create', async () => { + // Pre-populate ironrdp-transfers/ with stale and fresh entries. + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers', { create: true }); + + const staleTimestamp = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago + const freshTimestamp = Date.now() - 1 * 60 * 60 * 1000; // 1 hour ago + const staleId = `s-${staleTimestamp}-abc123`; + const freshId = `s-${freshTimestamp}-def456`; + + await transfersDir.getDirectoryHandle(staleId, { create: true }); + await transfersDir.getDirectoryHandle(freshId, { create: true }); + + // Creating a new backend triggers cleanupStale (fire-and-forget). + const backend = await OpfsStorageBackend.create(mockOpfsRoot, `s-${Date.now()}-new000`); + await new Promise((r) => setTimeout(r, 10)); + + // The stale directory should have been removed. + let staleExists = true; + try { + await transfersDir.getDirectoryHandle(staleId); + } catch { + staleExists = false; + } + expect(staleExists).toBe(false); + + // The fresh directory should still exist. + const freshDir = await transfersDir.getDirectoryHandle(freshId); + expect(freshDir.name).toBe(freshId); + + await backend.dispose(); + }); + + it('skips directories with unparsable names', async () => { + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers', { create: true }); + + // Non-matching names should be left alone. + await transfersDir.getDirectoryHandle('custom-dir', { create: true }); + + const backend = await OpfsStorageBackend.create(mockOpfsRoot, `s-${Date.now()}-test00`); + await new Promise((r) => setTimeout(r, 10)); + + const customDir = await transfersDir.getDirectoryHandle('custom-dir'); + expect(customDir.name).toBe('custom-dir'); + + await backend.dispose(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// detectStorageBackend +// --------------------------------------------------------------------------- + +describe('detectStorageBackend', () => { + const originalNavigator = globalThis.navigator; + + afterEach(() => { + // Restore navigator after each test + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + it('returns BlobStorageBackend when preference is "blob"', async () => { + const backend = await detectStorageBackend('blob'); + expect(backend.name).toBe('blob'); + }); + + it('returns BlobStorageBackend when navigator.storage is unavailable', async () => { + Object.defineProperty(globalThis, 'navigator', { + value: {}, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend('auto'); + expect(backend.name).toBe('blob'); + }); + + it('returns BlobStorageBackend when getDirectory throws', async () => { + Object.defineProperty(globalThis, 'navigator', { + value: { + storage: { + getDirectory: () => Promise.reject(new Error('SecurityError')), + }, + }, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend('auto'); + expect(backend.name).toBe('blob'); + }); + + it('defaults to auto when no preference given', async () => { + Object.defineProperty(globalThis, 'navigator', { + value: {}, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend(); + expect(backend.name).toBe('blob'); + }); + + it('returns BlobStorageBackend when OPFS probe fails', async () => { + // getDirectory() succeeds, but createWritable() throws inside probe(). + const brokenRoot = { + ...createMockDirectoryHandle(''), + async getFileHandle() { + return { + async createWritable() { + throw new Error('SecurityError'); + }, + } as unknown as FileSystemFileHandle; + }, + } as unknown as FileSystemDirectoryHandle; + + Object.defineProperty(globalThis, 'navigator', { + value: { + storage: { + getDirectory: () => Promise.resolve(brokenRoot), + }, + }, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend('auto'); + expect(backend.name).toBe('blob'); + }); + + it('returns OpfsStorageBackend when OPFS is available', async () => { + const mockRoot = createMockDirectoryHandle(''); + Object.defineProperty(globalThis, 'navigator', { + value: { + storage: { + getDirectory: () => Promise.resolve(mockRoot), + }, + }, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend('auto'); + expect(backend.name).toBe('opfs'); + await backend.dispose(); + }); +}); From a4fde9fc50f41d1534f32e619bbe0bbbddc64f25 Mon Sep 17 00:00:00 2001 From: Rocco De Angelis Date: Tue, 23 Jun 2026 19:53:46 +0100 Subject: [PATCH 284/325] fix(connector): stay in CapabilitiesExchange when activation handles DeactivateAll (#1371) --- crates/ironrdp-connector/src/connection.rs | 8 +++++++ .../tests/session/connection_activation.rs | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-connector/src/connection.rs b/crates/ironrdp-connector/src/connection.rs index 95b53bd4a0..55ab284bb2 100644 --- a/crates/ironrdp-connector/src/connection.rs +++ b/crates/ironrdp-connector/src/connection.rs @@ -558,6 +558,14 @@ impl Sequence for ClientConnector { written, ClientConnectorState::ConnectionFinalization { connection_activation }, ), + // The inner sequence stays in CapabilitiesExchange when it receives a + // Server Deactivate All PDU before the Server Demand Active PDU (sent + // by e.g. Windows Server and gnome-remote-desktop); mirror it here and + // wait for the next input. + ConnectionActivationState::CapabilitiesExchange { .. } => ( + written, + ClientConnectorState::CapabilitiesExchange { connection_activation }, + ), _ => return Err(general_err!("invalid state (this is a bug)")), } } diff --git a/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs b/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs index 79887d371c..d865dba60d 100644 --- a/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs +++ b/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use ironrdp_connector::connection_activation::{ConnectionActivationSequence, ConnectionActivationState}; -use ironrdp_connector::{Credentials, DesktopSize, Sequence as _, Written}; +use ironrdp_connector::{ClientConnector, ClientConnectorState, Credentials, DesktopSize, Sequence as _, Written}; use ironrdp_core::{WriteBuf, encode_vec}; use ironrdp_pdu::gcc; use ironrdp_pdu::mcs::{McsMessage, SendDataIndication}; @@ -95,6 +95,26 @@ fn deactivate_all_during_capabilities_exchange_stays_in_same_state() { ); } +#[test] +fn client_connector_stays_in_capabilities_exchange_on_deactivate_all() { + let config = test_config(); + let mut connector = ClientConnector::new(config.clone(), "127.0.0.1:3389".parse().unwrap()); + connector.state = ClientConnectorState::CapabilitiesExchange { + connection_activation: ConnectionActivationSequence::new(config, IO_CHANNEL_ID, USER_CHANNEL_ID), + }; + + let frame = encode_server_share_control(ShareControlPdu::ServerDeactivateAll(ServerDeactivateAll)); + let mut output = WriteBuf::new(); + + let written = connector.step(&frame, &mut output).unwrap(); + + assert_eq!(written, Written::Nothing); + assert!( + matches!(connector.state, ClientConnectorState::CapabilitiesExchange { .. }), + "outer connector state should remain CapabilitiesExchange after DeactivateAll" + ); +} + #[test] fn demand_active_after_deactivate_all_transitions_to_connection_finalization() { let config = test_config(); From efa573280572f3c0f0270a40ae51a154562706cc Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 24 Jun 2026 02:59:42 -0500 Subject: [PATCH 285/325] feat(acceptor): negotiate the MCS message channel (#1347) Updates the handshake to properly negotiate the MCS message channel by advertising Extended Client Data Blocks support and, when requested by the client, allocating/joining the message channel and surfacing its ID in AcceptorResult. This enables server-initiated PDUs that must use the message channel (e.g., network auto-detect) to have a valid transport. --- crates/ironrdp-acceptor/src/connection.rs | 36 ++++++++-- .../tests/server/acceptor.rs | 68 ++++++++++++++++++- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/crates/ironrdp-acceptor/src/connection.rs b/crates/ironrdp-acceptor/src/connection.rs index 269c3377a0..2a9be03145 100644 --- a/crates/ironrdp-acceptor/src/connection.rs +++ b/crates/ironrdp-acceptor/src/connection.rs @@ -29,6 +29,7 @@ pub struct Acceptor { security: SecurityProtocol, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, desktop_size: DesktopSize, server_capabilities: Vec, static_channels: StaticChannelSet, @@ -45,6 +46,13 @@ pub struct AcceptorResult { pub input_events: Vec>, pub user_channel_id: u16, pub io_channel_id: u16, + /// MCS channel ID of the message channel, present when the client requested + /// one via Client Message Channel Data (section 2.2.1.3.7). + /// + /// Server-initiated PDUs that ride the message channel (network auto-detect + /// per section 2.2.14, multitransport bootstrap, heartbeat) are sent on this + /// channel. `None` when the client did not request it. + pub message_channel_id: Option, pub reactivation: bool, /// Credentials received from the client during SecureSettingsExchange. /// @@ -69,6 +77,7 @@ impl Acceptor { state: AcceptorState::InitiationWaitRequest, user_channel_id: USER_CHANNEL_ID, io_channel_id: IO_CHANNEL_ID, + message_channel_id: None, desktop_size, server_capabilities: capabilities, static_channels: StaticChannelSet::new(), @@ -111,6 +120,7 @@ impl Acceptor { state, user_channel_id: consumed.user_channel_id, io_channel_id: consumed.io_channel_id, + message_channel_id: consumed.message_channel_id, desktop_size, server_capabilities: consumed.server_capabilities, static_channels, @@ -170,6 +180,7 @@ impl Acceptor { input_events, user_channel_id: self.user_channel_id, io_channel_id: self.io_channel_id, + message_channel_id: self.message_channel_id, reactivation: self.reactivation, credentials: self.received_credentials.take(), }), @@ -364,7 +375,7 @@ impl Sequence for Acceptor { )); }; let connection_confirm = nego::ConnectionConfirm::Response { - flags: nego::ResponseFlags::empty(), + flags: nego::ResponseFlags::EXTENDED_CLIENT_DATA_SUPPORTED, protocol, }; @@ -426,6 +437,7 @@ impl Sequence for Acceptor { let gcc_blocks = settings_initial.conference_create_request.into_gcc_blocks(); let early_capability = gcc_blocks.core.optional_data.early_capability_flags; + let client_wants_message_channel = gcc_blocks.message_channel.is_some(); let joined: Vec<_> = gcc_blocks .network @@ -443,7 +455,7 @@ impl Sequence for Acceptor { .unwrap_or_default(); #[expect(clippy::arithmetic_side_effects)] // IO channel ID is not big enough for overflowing. - let channels = joined + let channels: Vec<_> = joined .into_iter() .enumerate() .map(|(i, channel)| { @@ -457,6 +469,16 @@ impl Sequence for Acceptor { }) .collect(); + if client_wants_message_channel { + // Allocate the message channel ID after the I/O channel and + // any static virtual channels. It is advertised in Server + // Message Channel Data and joined alongside the others. + #[expect(clippy::arithmetic_side_effects)] // IO channel ID is not big enough for overflowing. + let channel_id = + u16::try_from(channels.len()).expect("always in the range") + self.io_channel_id + 1; + self.message_channel_id = Some(channel_id); + } + ( Written::Nothing, AcceptorState::BasicSettingsSendResponse { @@ -484,6 +506,7 @@ impl Sequence for Acceptor { channel_ids.clone(), requested_protocol, skip_channel_join, + self.message_channel_id, ); let settings_response = mcs::ConnectResponse { @@ -507,7 +530,9 @@ impl Sequence for Acceptor { connection: if skip_channel_join { ChannelConnectionSequence::skip_channel_join(self.user_channel_id) } else { - ChannelConnectionSequence::new(self.user_channel_id, self.io_channel_id, channel_ids) + let mut join_channel_ids = channel_ids; + join_channel_ids.extend(self.message_channel_id); + ChannelConnectionSequence::new(self.user_channel_id, self.io_channel_id, join_channel_ids) }, }, ) @@ -781,6 +806,7 @@ fn create_gcc_blocks( channel_ids: Vec, requested: SecurityProtocol, skip_channel_join: bool, + message_channel_id: Option, ) -> gcc::ServerGccBlocks { gcc::ServerGccBlocks { core: gcc::ServerCoreData { @@ -796,7 +822,9 @@ fn create_gcc_blocks( channel_ids, io_channel, }, - message_channel: None, + message_channel: message_channel_id.map(|id| gcc::ServerMessageChannelData { + mcs_message_channel_id: id, + }), multi_transport_channel: None, } } diff --git a/crates/ironrdp-testsuite-core/tests/server/acceptor.rs b/crates/ironrdp-testsuite-core/tests/server/acceptor.rs index 61af240445..30702297ec 100644 --- a/crates/ironrdp-testsuite-core/tests/server/acceptor.rs +++ b/crates/ironrdp-testsuite-core/tests/server/acceptor.rs @@ -1,8 +1,11 @@ use ironrdp_acceptor::Acceptor; -use ironrdp_connector::{DesktopSize, Sequence as _, Written}; +use ironrdp_connector::{DesktopSize, Sequence as _, Written, encode_x224_packet}; use ironrdp_core::{WriteBuf, decode}; +use ironrdp_pdu::gcc::ClientMessageChannelData; +use ironrdp_pdu::mcs::{self, ConnectInitial}; use ironrdp_pdu::nego::{self, SecurityProtocol}; -use ironrdp_pdu::x224::X224; +use ironrdp_pdu::x224::{X224, X224Data}; +use ironrdp_testsuite_core::gcc::CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS; /// Build a minimal ConnectionRequest with the given protocols and encode it. fn encode_connection_request(protocol: SecurityProtocol) -> Vec { @@ -83,8 +86,12 @@ fn neg_success_when_protocols_match() { let response_bytes = output.filled(); let confirm = decode::>(response_bytes).unwrap().0; match confirm { - nego::ConnectionConfirm::Response { protocol, .. } => { + nego::ConnectionConfirm::Response { protocol, flags } => { assert_eq!(protocol, SecurityProtocol::SSL); + // The acceptor advertises support for Extended Client Data Blocks so the + // client sends its Client Message Channel Data, enabling the message + // channel to be negotiated. + assert!(flags.contains(nego::ResponseFlags::EXTENDED_CLIENT_DATA_SUPPORTED)); } nego::ConnectionConfirm::Failure { .. } => { panic!("expected Response, got Failure"); @@ -92,6 +99,61 @@ fn neg_success_when_protocols_match() { } } +/// When the client advertises the message channel (Client Message Channel Data), +/// the acceptor allocates an MCS channel ID for it and returns it in Server +/// Message Channel Data. The ID is allocated after the I/O channel and any +/// static virtual channels, so the expected value is derived from the server's +/// network block rather than hard-coded. +#[test] +fn message_channel_advertised_when_client_requests_it() { + let mut acceptor = Acceptor::new( + SecurityProtocol::SSL, + DesktopSize { + width: 1920, + height: 1080, + }, + Vec::new(), + None, + ); + + // Connection request -> confirm -> (TLS upgrade) -> ready for ConnectInitial. + let request_bytes = encode_connection_request(SecurityProtocol::SSL); + acceptor.step(&request_bytes, &mut WriteBuf::new()).unwrap(); + acceptor.step(&[], &mut WriteBuf::new()).unwrap(); + acceptor.mark_security_upgrade_as_done(); + + // Client GCC with the message channel block and no network channels, so the + // allocated ID is deterministic. + let mut blocks = CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS.clone(); + blocks.network = None; + blocks.message_channel = Some(ClientMessageChannelData); + let connect_initial = ConnectInitial::with_gcc_blocks(blocks).unwrap(); + let mut initial_buf = WriteBuf::new(); + encode_x224_packet(&connect_initial, &mut initial_buf).unwrap(); + + acceptor.step(initial_buf.filled(), &mut WriteBuf::new()).unwrap(); + + let mut output = WriteBuf::new(); + acceptor.step(&[], &mut output).unwrap(); + + let payload = decode::>>(output.filled()).unwrap().0; + let response = decode::(payload.data.as_ref()).unwrap(); + let server_blocks = response.conference_create_response.gcc_blocks(); + + let message_channel = server_blocks + .message_channel + .as_ref() + .expect("acceptor must advertise Server Message Channel Data"); + + // The message channel is allocated after the I/O channel and any static + // virtual channels, so derive the expected ID from the server's network + // block instead of coupling the assertion to the I/O channel base. + let network = &server_blocks.network; + let channel_count = u16::try_from(network.channel_ids.len()).expect("channel count fits in u16"); + let expected = network.io_channel + channel_count + 1; + assert_eq!(message_channel.mcs_message_channel_id, expected); +} + /// When server requires HYBRID but client only offers SSL, the failure code /// should be HYBRID_REQUIRED_BY_SERVER. #[test] From 37483ebd9b7628325666f434e1679e7f885fb289 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 24 Jun 2026 03:01:08 -0500 Subject: [PATCH 286/325] fix(rdpsnd-native)!: replace anyhow with typed RdpsndNativeError (#1277) --- Cargo.lock | 1 + crates/ironrdp-rdpsnd-native/Cargo.toml | 3 +- crates/ironrdp-rdpsnd-native/src/cpal.rs | 47 +++++++++++++++-------- crates/ironrdp-rdpsnd-native/src/error.rs | 39 +++++++++++++++++++ crates/ironrdp-rdpsnd-native/src/lib.rs | 11 ++++-- 5 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 crates/ironrdp-rdpsnd-native/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 209e533617..0ef3ed475e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2830,6 +2830,7 @@ dependencies = [ "anyhow", "bytemuck", "cpal", + "ironrdp-error", "ironrdp-rdpsnd", "opus2", "tracing", diff --git a/crates/ironrdp-rdpsnd-native/Cargo.toml b/crates/ironrdp-rdpsnd-native/Cargo.toml index c4c02f6eb3..ec7156aca9 100644 --- a/crates/ironrdp-rdpsnd-native/Cargo.toml +++ b/crates/ironrdp-rdpsnd-native/Cargo.toml @@ -20,14 +20,15 @@ default = ["opus"] opus = ["dep:opus2", "dep:bytemuck"] [dependencies] -anyhow = "1" bytemuck = { version = "1.24", optional = true } cpal = "0.17" +ironrdp-error = { path = "../ironrdp-error", version = "0.2", features = ["std"] } # public ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8" } # public opus2 = { version = "0.4", optional = true, features = ["bundled"] } tracing = { version = "0.1", features = ["log"] } [dev-dependencies] +anyhow = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [lints] diff --git a/crates/ironrdp-rdpsnd-native/src/cpal.rs b/crates/ironrdp-rdpsnd-native/src/cpal.rs index d2832d517e..0dae2acb4e 100644 --- a/crates/ironrdp-rdpsnd-native/src/cpal.rs +++ b/crates/ironrdp-rdpsnd-native/src/cpal.rs @@ -5,13 +5,15 @@ use std::sync::Arc; use std::sync::mpsc::{self, Receiver, Sender}; use std::thread::{self, JoinHandle}; -use anyhow::{Context as _, bail}; use cpal::traits::{DeviceTrait as _, HostTrait as _}; use cpal::{SampleFormat, Stream, StreamConfig}; +use ironrdp_error::bail; use ironrdp_rdpsnd::client::RdpsndClientHandler; use ironrdp_rdpsnd::pdu::{AudioFormat, PitchPdu, VolumePdu, WaveFormat}; use tracing::{debug, error, trace, warn}; +use crate::error::{RdpsndNativeError, RdpsndNativeErrorKind, RdpsndNativeResult}; + #[derive(Debug)] pub struct RdpsndBackend { // Unfortunately, Stream is not `Send`, so we move it to a separate thread. @@ -91,7 +93,7 @@ impl RdpsndClientHandler for RdpsndBackend { let stream = match DecodeStream::new(&format, rx) { Ok(stream) => stream, Err(e) => { - error!(error = format!("{e:#}")); + error!(error = %e.report()); return; } }; @@ -138,7 +140,7 @@ pub struct DecodeStream { } impl DecodeStream { - pub fn new(rx_format: &AudioFormat, mut rx: Receiver>) -> anyhow::Result { + pub fn new(rx_format: &AudioFormat, mut rx: Receiver>) -> RdpsndNativeResult { let mut dec_thread = None; match rx_format.format { #[cfg(feature = "opus")] @@ -146,10 +148,15 @@ impl DecodeStream { let chan = match rx_format.n_channels { 1 => opus2::Channels::Mono, 2 => opus2::Channels::Stereo, - _ => bail!("unsupported #channels for Opus"), + _ => bail!( + "unsupported channel count for Opus", + RdpsndNativeErrorKind::UnsupportedFormat, + ), }; let (dec_tx, dec_rx) = mpsc::channel(); - let mut dec = opus2::Decoder::new(rx_format.n_samples_per_sec, chan)?; + let mut dec = opus2::Decoder::new(rx_format.n_samples_per_sec, chan).map_err(|e| { + RdpsndNativeError::new("creating Opus decoder", RdpsndNativeErrorKind::OpusInit).with_source(e) + })?; dec_thread = Some(thread::spawn(move || { while let Ok(pkt) = rx.recv() { let nb_samples = match dec.get_nb_samples(&pkt) { @@ -189,23 +196,31 @@ impl DecodeStream { rx = dec_rx; } WaveFormat::PCM => {} - _ => bail!("audio format not supported"), + _ => bail!( + "matching server-requested wave format", + RdpsndNativeErrorKind::UnsupportedFormat, + ), } let sample_format = match rx_format.bits_per_sample { 8 => SampleFormat::U8, 16 => SampleFormat::I16, - _ => { - bail!("only PCM 8/16 bits formats supported"); - } + _ => bail!( + "only PCM 8/16 bit formats supported", + RdpsndNativeErrorKind::UnsupportedFormat, + ), }; let host = cpal::default_host(); - let device = host.default_output_device().context("no default output device")?; - let _supported_configs_range = device - .supported_output_configs() - .context("no supported output config")?; - let default_config = device.default_output_config()?; + let device = host + .default_output_device() + .ok_or_else(|| RdpsndNativeError::new("no default output device", RdpsndNativeErrorKind::AudioDevice))?; + let _supported_configs_range = device.supported_output_configs().map_err(|e| { + RdpsndNativeError::new("no supported output configs", RdpsndNativeErrorKind::AudioDevice).with_source(e) + })?; + let default_config = device.default_output_config().map_err(|e| { + RdpsndNativeError::new("default output config", RdpsndNativeErrorKind::AudioDevice).with_source(e) + })?; debug!(?default_config); let mut rx = RxBuffer::new(rx); @@ -227,7 +242,9 @@ impl DecodeStream { |error| error!(%error), None, ) - .context("failed to setup output stream")?; + .map_err(|e| { + RdpsndNativeError::new("building cpal output stream", RdpsndNativeErrorKind::StreamBuild).with_source(e) + })?; Ok(Self { _dec_thread: dec_thread, diff --git a/crates/ironrdp-rdpsnd-native/src/error.rs b/crates/ironrdp-rdpsnd-native/src/error.rs new file mode 100644 index 0000000000..1c84db69cc --- /dev/null +++ b/crates/ironrdp-rdpsnd-native/src/error.rs @@ -0,0 +1,39 @@ +//! Typed error types for `ironrdp-rdpsnd-native`. + +/// Categorises failures in `ironrdp-rdpsnd-native` operations. +/// +/// Bug-shaped conditions are intentionally absent: misuse of this crate's +/// public API should panic or trip `debug_assert!`, not return `Err`. +#[derive(Debug)] +#[non_exhaustive] +pub enum RdpsndNativeErrorKind { + /// Server requested an audio format outside the supported set (wave + /// format, channel count, or bit depth). + UnsupportedFormat, + /// The Opus decoder failed to initialise. Source carries the underlying + /// `opus2::Error` when available. + OpusInit, + /// No usable audio output device or no supported output configuration + /// for the requested format. Source carries the underlying `cpal` error + /// when available. + AudioDevice, + /// The `cpal` output stream could not be built. Source carries the + /// underlying `cpal::BuildStreamError`. + StreamBuild, +} + +impl core::fmt::Display for RdpsndNativeErrorKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UnsupportedFormat => write!(f, "unsupported audio format"), + Self::OpusInit => write!(f, "Opus decoder initialisation"), + Self::AudioDevice => write!(f, "audio output device"), + Self::StreamBuild => write!(f, "output audio stream build"), + } + } +} + +impl core::error::Error for RdpsndNativeErrorKind {} + +pub type RdpsndNativeError = ironrdp_error::Error; +pub type RdpsndNativeResult = Result; diff --git a/crates/ironrdp-rdpsnd-native/src/lib.rs b/crates/ironrdp-rdpsnd-native/src/lib.rs index 04a3b3cfa7..5d2c3d3c88 100644 --- a/crates/ironrdp-rdpsnd-native/src/lib.rs +++ b/crates/ironrdp-rdpsnd-native/src/lib.rs @@ -1,7 +1,12 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] - -#[cfg(test)] -use tracing_subscriber as _; +// `anyhow` and `tracing-subscriber` are dev-deps used only by the `cpal` +// example binary, but `unused_crate_dependencies` still flags them on the +// lib target. The `[lib] test = false` setting makes a `#[cfg(test)]` +// workaround dead code, so the suppression has to apply unconditionally. +#![allow(unused_crate_dependencies)] pub mod cpal; +pub mod error; + +pub use error::{RdpsndNativeError, RdpsndNativeErrorKind, RdpsndNativeResult}; From 481ea5d161964b06a08f0b1ace0a1efd11773b4a Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 24 Jun 2026 03:03:02 -0500 Subject: [PATCH 287/325] feat(server): expose NetworkAutoDetect RTT via a shared handle (#1346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the server’s NetworkAutoDetect RTT measurement via a shared Arc handle so display backends can read a fresh RTT value even after run() takes ownership of the server. --- crates/ironrdp-server/src/builder.rs | 17 +++++++- crates/ironrdp-server/src/server.rs | 42 ++++++++++++++----- .../tests/server/autodetect.rs | 42 +++++++++++++++++++ 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index fb959830ce..f9c52d9d8a 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -1,5 +1,5 @@ use core::net::SocketAddr; -use core::sync::atomic::AtomicBool; +use core::sync::atomic::{AtomicBool, AtomicU32}; use std::sync::Arc; use anyhow::Result; @@ -41,6 +41,7 @@ pub struct BuilderDone { #[cfg(feature = "egfx")] gfx_factory: Option>, display_suppressed: Option>, + autodetect_rtt: Option>, } pub struct RdpServerBuilder { @@ -140,6 +141,7 @@ impl RdpServerBuilder { #[cfg(feature = "egfx")] gfx_factory: None, display_suppressed: None, + autodetect_rtt: None, }, } } @@ -160,6 +162,7 @@ impl RdpServerBuilder { #[cfg(feature = "egfx")] gfx_factory: None, display_suppressed: None, + autodetect_rtt: None, }, } } @@ -241,6 +244,17 @@ impl RdpServerBuilder { self } + /// Inject a shared NetworkAutoDetect RTT handle (milliseconds, `u32::MAX` + /// until the first measurement). The server writes the latest measured RTT + /// to the same instance the backend reads. When not called, the server + /// allocates its own (still readable via + /// [`RdpServer::autodetect_rtt_handle`]). The value stays `u32::MAX` unless + /// auto-detect is enabled via [`RdpServer::enable_autodetect`]. + pub fn with_autodetect_rtt_handle(mut self, handle: Arc) -> Self { + self.state.autodetect_rtt = Some(handle); + self + } + pub fn build(self) -> RdpServer { let mut server = RdpServer::new( RdpServerOptions { @@ -257,6 +271,7 @@ impl RdpServerBuilder { #[cfg(feature = "egfx")] self.state.gfx_factory, self.state.display_suppressed, + self.state.autodetect_rtt, ); server.set_credential_validator(self.state.credential_validator); server diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 0be2a0707b..2e57ab1053 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1,6 +1,6 @@ use core::fmt; use core::net::SocketAddr; -use core::sync::atomic::{AtomicBool, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use core::time::Duration; use std::rc::Rc; use std::sync::Arc; @@ -441,6 +441,14 @@ pub struct RdpServer { /// and locks up its input dispatch for seconds on refocus while it /// chews through the backlog. display_suppressed: Arc, + + /// Latest NetworkAutoDetect round-trip time in milliseconds, or `u32::MAX` + /// until the first measurement (and while auto-detect is disabled). Updated + /// on each RTT Measure Response when auto-detect is enabled (see + /// [`Self::enable_autodetect`]). Exposed via [`Self::autodetect_rtt_handle`] + /// so display backends can read a fresh, frame-traffic-independent network + /// RTT for flow control. + autodetect_rtt: Arc, } #[derive(Debug)] @@ -475,17 +483,11 @@ enum RunState { } impl RdpServer { - // The lint only fires with the `egfx` feature on (8 args including - // `gfx_factory`); without it the parameter count is 7 and the lint - // is satisfied. `cfg_attr` keeps `#[expect]` strict in both modes. - #[cfg_attr( - feature = "egfx", - expect( - clippy::too_many_arguments, - reason = "called via the builder; positional parameters are an internal detail" - ) + #[expect( + clippy::too_many_arguments, + reason = "called via the builder; positional parameters are an internal detail" )] - pub fn new( + pub(crate) fn new( opts: RdpServerOptions, handler: Box, display: Box, @@ -494,6 +496,7 @@ impl RdpServer { connection_handler: Option>, #[cfg(feature = "egfx")] mut gfx_factory: Option>, display_suppressed: Option>, + autodetect_rtt: Option>, ) -> Self { let (ev_sender, ev_receiver) = ServerEvent::create_channel(); if let Some(cliprdr) = cliprdr_factory.as_mut() { @@ -526,6 +529,12 @@ impl RdpServer { autodetect: None, connection_handler, display_suppressed: display_suppressed.unwrap_or_else(|| Arc::new(AtomicBool::new(false))), + autodetect_rtt: { + // Reset to the sentinel: an injected handle must not expose a stale value before the first measurement. + let handle = autodetect_rtt.unwrap_or_else(|| Arc::new(AtomicU32::new(u32::MAX))); + handle.store(u32::MAX, Ordering::Relaxed); + handle + }, } } @@ -589,6 +598,16 @@ impl RdpServer { Arc::clone(&self.display_suppressed) } + /// Returns a handle to the latest NetworkAutoDetect RTT in milliseconds + /// (`u32::MAX` until the first measurement, and while auto-detect is + /// disabled). The server updates it on each RTT Measure Response; backends + /// clone the handle to read a fresh network RTT for flow control. Inject a + /// shared instance at construction with + /// [`RdpServerBuilder::with_autodetect_rtt_handle`](crate::RdpServerBuilder::with_autodetect_rtt_handle). + pub fn autodetect_rtt_handle(&self) -> Arc { + Arc::clone(&self.autodetect_rtt) + } + /// Returns the shared ECHO server handle for runtime probe requests and RTT measurements. pub fn echo_handle(&self) -> &EchoServerHandle { &self.echo_handle @@ -1408,6 +1427,7 @@ impl RdpServer { rdp::headers::ShareDataPdu::AutoDetectRsp(response) => { if let Some(ref mut ad) = self.autodetect { if let Some(rtt_ms) = ad.handle_response(&response) { + self.autodetect_rtt.store(rtt_ms, Ordering::Relaxed); debug!(rtt_ms, seq = response.sequence_number(), "RTT measured"); } else { trace!(seq = response.sequence_number(), "Unmatched auto-detect response"); diff --git a/crates/ironrdp-testsuite-core/tests/server/autodetect.rs b/crates/ironrdp-testsuite-core/tests/server/autodetect.rs index b3a3f286a1..a104040e82 100644 --- a/crates/ironrdp-testsuite-core/tests/server/autodetect.rs +++ b/crates/ironrdp-testsuite-core/tests/server/autodetect.rs @@ -77,6 +77,48 @@ fn sequence_number_wraps_at_u16_max() { assert_eq!(req2.sequence_number(), 0, "should wrap around"); } +#[test] +fn autodetect_rtt_handle_defaults_to_sentinel() { + use core::net::{Ipv4Addr, SocketAddr}; + use core::sync::atomic::Ordering; + + use ironrdp_server::RdpServer; + + let server = RdpServer::builder() + .with_addr(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .with_no_security() + .with_no_input() + .with_no_display() + .build(); + + assert_eq!(server.autodetect_rtt_handle().load(Ordering::Relaxed), u32::MAX); +} + +#[test] +fn with_autodetect_rtt_handle_round_trips_the_same_arc() { + use core::net::{Ipv4Addr, SocketAddr}; + use core::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + use ironrdp_server::RdpServer; + + let handle = Arc::new(AtomicU32::new(42)); + let server = RdpServer::builder() + .with_addr(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .with_no_security() + .with_no_input() + .with_no_display() + .with_autodetect_rtt_handle(Arc::clone(&handle)) + .build(); + + assert!(Arc::ptr_eq(&handle, &server.autodetect_rtt_handle())); + // The server resets an injected handle to the sentinel at construction. + assert_eq!(server.autodetect_rtt_handle().load(Ordering::Relaxed), u32::MAX); + // The Arc is shared: mutating the original is visible through the server's handle. + handle.store(42, Ordering::Relaxed); + assert_eq!(server.autodetect_rtt_handle().load(Ordering::Relaxed), 42); +} + #[test] fn stale_probe_expiry() { let mut mgr = AutoDetectManager::new(); From 45ec1ef4ab874f348d279e29f546b3f76907e6de Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 24 Jun 2026 03:16:37 -0500 Subject: [PATCH 288/325] refactor(pdu): make the CapabilitySet encoder exhaustive (#1328) --- .../rdp/capability_sets/bitmap_codecs/mod.rs | 7 ++++- .../src/rdp/capability_sets/mod.rs | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs index 59687dd4fa..3143de072d 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs @@ -281,7 +281,12 @@ impl<'de> Decode<'de> for Codec { match guid { GUID_REMOTEFX => CodecProperty::RemoteFx(property), GUID_IMAGE_REMOTEFX => CodecProperty::ImageRemoteFx(property), - _ => unreachable!(), + // `guid` is validated as RemoteFX or ImageRemoteFX by the outer + // match arm, so the `_` branch is genuinely dead. Keep it as a + // redundant correctness check that fires loudly under tests and + // fuzzing if a future change to the outer arm breaks that + // invariant. Not reachable from the wire. + _ => unreachable!("guid validated as RemoteFX or ImageRemoteFX by the outer match"), } } GUID_IGNORE => CodecProperty::Ignore, diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs index 555888525a..3e894bf03d 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs @@ -445,7 +445,34 @@ impl Encode for CapabilitySet { CapabilitySet::Rail(buffer) => (CapabilitySetType::Rail, buffer), CapabilitySet::WindowList(buffer) => (CapabilitySetType::WindowList, buffer), CapabilitySet::BitmapCacheV3(buffer) => (CapabilitySetType::BitmapCacheV3CodecID, buffer), - _ => unreachable!(), + // Structured variants are routed through the outer match's + // specific arms above this block and cannot reach this + // inner match. Listing them explicitly (instead of using + // `_ =>`) makes a future addition to `CapabilitySet` a + // compile error here until the new variant is routed in + // this `Encode` impl. PR #1313 (BitmapCacheV3 encoder + // `unreachable!()` reached on decoder-accepted input) + // demonstrated why a runtime catch-all is the wrong shape + // for this match. + CapabilitySet::General(_) + | CapabilitySet::Bitmap(_) + | CapabilitySet::Order(_) + | CapabilitySet::BitmapCache(_) + | CapabilitySet::BitmapCacheRev2(_) + | CapabilitySet::Pointer(_) + | CapabilitySet::Sound(_) + | CapabilitySet::Input(_) + | CapabilitySet::Brush(_) + | CapabilitySet::GlyphCache(_) + | CapabilitySet::OffscreenBitmapCache(_) + | CapabilitySet::VirtualChannel(_) + | CapabilitySet::MultiFragmentUpdate(_) + | CapabilitySet::LargePointer(_) + | CapabilitySet::SurfaceCommands(_) + | CapabilitySet::BitmapCodecs(_) + | CapabilitySet::FrameAcknowledge(_) => { + unreachable!("structured variant routed to raw-buffer encoder arm") + } }; dst.write_u16(capability_set_type.as_u16()); From d3705af18cff1851f4d48017affcb85aaa678d57 Mon Sep 17 00:00:00 2001 From: "irvingouj@Devolutions" Date: Thu, 25 Jun 2026 14:53:02 -0400 Subject: [PATCH 289/325] perf(web): replace softbuffer with direct put_image_data canvas present (#1374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The web client presented frames through `softbuffer`, whose web backend repacks the **whole surface** (RGBA → u32 → RGBA into a fresh buffer) on every present. This replaces it with a direct `put_image_data` that uploads only the dirty region, and drops the `softbuffer` dependency. Same idea as the IronVNC change. ## What changed - Remove the `softbuffer` dependency; present each dirty region with `put_image_data` at its origin. - No full-surface buffer and no per-region scratch. `extract_partial_image` fills a single `WriteBuf` reused across frames, so steady-state draws don't allocate. - Force opaque alpha before upload (kept — see Correctness). - Add `WriteBuf::filled_mut` to `ironrdp-core` (mutable counterpart of `filled`). - `web-sys`: add `CanvasRenderingContext2d` + `ImageData`, drop the softbuffer-only features. ## Performance Draw-stage time on a 1080p replay (595 frames / 110 dirty regions), headless Chromium, 8 measured passes × 3 runs, median. Both rows are reproducible branches off the replay-bench harness; the only difference is the render path. | Render path | draw (ms) | vs softbuffer | branch | |---|--:|--:|---| | softbuffer `present_with_damage` | ~1031 | — | `bench/draw-softbuffer` | | this PR (direct upload, reused `WriteBuf`) | ~97 | **~10.6×** | `bench/draw-zerocopy` | - The win is structural: upload the dirty region instead of repacking the whole surface every present. - Reusing one `WriteBuf` (vs a per-frame allocation) keeps the steady-state draw allocation-free; the remaining cost is the unavoidable `ImageData` JS copy. - Output is **byte-identical**: framebuffer CRC32 `2d8e1b79` matches the recorded ground truth and the rendered-canvas FNV-1a is unchanged. - Absolute ms carry ~±15% noise from machine load (decode drifted 1.5–1.9 s); the ratio held across runs. Reproduce: ```sh git checkout bench/draw-softbuffer # or bench/draw-zerocopy cd crates/ironrdp-web && wasm-pack build --target web --release -- --features bench cd bench-harness && node run.mjs --capture /bench-corpus/.irdprec --passes 8 ``` ## Correctness `put_image_data` stores alpha verbatim, and the decoded framebuffer isn't guaranteed opaque — it's zero-initialised, a widened whole-rows region can cover not-yet-painted columns (alpha 0), and the QOI-RGBA path copies source alpha. So we force alpha opaque before upload. A scan-then-conditionally-force was tried and is *slower* than just forcing (the check touches the same bytes), so the unconditional force stays. ## Follow-up (separate PR) Guarantee framebuffer opacity upstream in `ironrdp-session` (init alpha to `0xff` + clamp `apply_rgba32`); after that the web side can drop the alpha force entirely. --- Cargo.lock | 1 - crates/ironrdp-core/src/write_buf.rs | 6 ++ crates/ironrdp-web/Cargo.toml | 10 +- crates/ironrdp-web/src/canvas.rs | 133 ++++++++++++--------------- crates/ironrdp-web/src/image.rs | 39 +++++--- crates/ironrdp-web/src/session.rs | 12 ++- 6 files changed, 108 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ef3ed475e..e4904a28f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3049,7 +3049,6 @@ dependencies = [ "rgb", "semver", "smallvec", - "softbuffer", "tap", "time", "tracing", diff --git a/crates/ironrdp-core/src/write_buf.rs b/crates/ironrdp-core/src/write_buf.rs index 09023c0080..8316439e0b 100644 --- a/crates/ironrdp-core/src/write_buf.rs +++ b/crates/ironrdp-core/src/write_buf.rs @@ -62,6 +62,12 @@ impl WriteBuf { &self.inner[..self.filled] } + /// Returns a mutable reference to the filled portion of the buffer. + #[inline] + pub fn filled_mut(&mut self) -> &mut [u8] { + &mut self.inner[..self.filled] + } + /// Ensures initialized and unfilled portion of the buffer is big enough for `additional` more bytes. #[inline] pub fn initialize(&mut self, additional: usize) { diff --git a/crates/ironrdp-web/Cargo.toml b/crates/ironrdp-web/Cargo.toml index e5b75a5015..8771c4b29c 100644 --- a/crates/ironrdp-web/Cargo.toml +++ b/crates/ironrdp-web/Cargo.toml @@ -51,13 +51,19 @@ iron-remote-desktop.path = "../iron-remote-desktop" # WASM wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["HtmlCanvasElement", "Navigator", "Performance", "Window"] } +web-sys = { version = "0.3", features = [ + "CanvasRenderingContext2d", + "HtmlCanvasElement", + "ImageData", + "Navigator", + "Performance", + "Window", +] } js-sys = "0.3" gloo-net = { version = "0.7", default-features = false, features = ["websocket", "http", "io-util"] } gloo-timers = { version = "0.4", default-features = false, features = ["futures"] } # Rendering -softbuffer = { version = "0.4", default-features = false } png = "0.18" resize = { version = "0.8", features = ["std"], default-features = false } rgb = "0.8" diff --git a/crates/ironrdp-web/src/canvas.rs b/crates/ironrdp-web/src/canvas.rs index 96b9df50d3..30ba5be78f 100644 --- a/crates/ironrdp-web/src/canvas.rs +++ b/crates/ironrdp-web/src/canvas.rs @@ -1,93 +1,82 @@ use core::num::NonZeroU32; -use anyhow::Context as _; -use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _}; -use softbuffer::{NoDisplayHandle, NoWindowHandle}; -use web_sys::HtmlCanvasElement; - +#[cfg(target_arch = "wasm32")] +use anyhow::anyhow; +use ironrdp::pdu::geometry::InclusiveRectangle; +#[cfg(target_arch = "wasm32")] +use ironrdp::pdu::geometry::Rectangle as _; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::{Clamped, JsCast as _}; +#[cfg(target_arch = "wasm32")] +use web_sys::ImageData; +use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement}; + +/// Web render surface: blits each dirty region to the canvas with `put_image_data`. pub(crate) struct Canvas { - width: NonZeroU32, - surface: softbuffer::Surface, + canvas: HtmlCanvasElement, + ctx: CanvasRenderingContext2d, } impl Canvas { pub(crate) fn new(render_canvas: HtmlCanvasElement, width: NonZeroU32, height: NonZeroU32) -> anyhow::Result { render_canvas.set_width(width.get()); render_canvas.set_height(height.get()); + let ctx = context_2d(&render_canvas)?; - #[cfg(target_arch = "wasm32")] - let mut surface = { - use softbuffer::SurfaceExtWeb as _; - softbuffer::Surface::from_canvas(render_canvas).expect("surface") - }; - - #[cfg(not(target_arch = "wasm32"))] - let mut surface = { - fn stub(_: HtmlCanvasElement) -> softbuffer::Surface { - unimplemented!() - } - - stub(render_canvas) - }; - - surface.resize(width, height).expect("surface resize"); - - Ok(Self { width, surface }) + Ok(Self { + canvas: render_canvas, + ctx, + }) } + /// Resizes the backing store. Note: this also clears the canvas and resets 2D context state; + /// the cached `ctx` stays valid. pub(crate) fn resize(&mut self, width: NonZeroU32, height: NonZeroU32) { - self.surface.resize(width, height).expect("surface resize"); - self.width = width; + self.canvas.set_width(width.get()); + self.canvas.set_height(height.get()); } - pub(crate) fn draw(&mut self, buffer: &[u8], region: InclusiveRectangle) -> anyhow::Result<()> { - let region_width = region.width(); - let region_height = region.height(); - - let mut src = buffer.chunks_exact(4).map(|pixel| { - let r = pixel[0]; - let g = pixel[1]; - let b = pixel[2]; - u32::from_be_bytes([0, r, g, b]) - }); - - let mut dst = self.surface.buffer_mut().expect("surface buffer"); + /// Blits a dirty region with `put_image_data`. Forces alpha opaque first: the framebuffer isn't + /// guaranteed opaque (zero-init columns, QOI-RGBA) and `put_image_data` stores alpha verbatim. + pub(crate) fn draw(&self, buffer: &mut [u8], region: InclusiveRectangle) -> anyhow::Result<()> { + for pixel in buffer.chunks_exact_mut(4) { + pixel[3] = 0xFF; + } + #[cfg(target_arch = "wasm32")] { - // Copy src into dst - - let region_top_usize = usize::from(region.top); - let region_height_usize = usize::from(region_height); - let region_left_usize = usize::from(region.left); - let region_width_usize = usize::from(region_width); - - for dst_row in dst - .chunks_exact_mut(usize::try_from(self.width.get()).context("canvas width")?) - .skip(region_top_usize) - .take(region_height_usize) - { - let src_row = src.by_ref().take(region_width_usize); - - dst_row - .iter_mut() - .skip(region_left_usize) - .take(region_width_usize) - .zip(src_row) - .for_each(|(dst, src)| *dst = src); - } + let image = ImageData::new_with_u8_clamped_array_and_sh( + Clamped(&*buffer), + u32::from(region.width()), + u32::from(region.height()), + ) + .map_err(|err| anyhow!("ImageData::new failed: {err:?}"))?; + self.ctx + .put_image_data(&image, f64::from(region.left), f64::from(region.top)) + .map_err(|err| anyhow!("put_image_data failed: {err:?}")) } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (&self.ctx, buffer, region); + unimplemented!("web canvas is only available on wasm32") + } + } +} - let damage_rect = softbuffer::Rect { - x: u32::from(region.left), - y: u32::from(region.top), - width: NonZeroU32::new(u32::from(region_width)) - .expect("per InclusiveRectangle invariants: 0 < region_width"), - height: NonZeroU32::new(u32::from(region_height)) - .expect("per InclusiveRectangle invariants: 0 < region_height"), - }; - - dst.present_with_damage(&[damage_rect]).expect("buffer present"); - - Ok(()) +/// Acquires the canvas 2D context (wasm only; panics on other targets). +fn context_2d(canvas: &HtmlCanvasElement) -> anyhow::Result { + #[cfg(target_arch = "wasm32")] + { + canvas + .get_context("2d") + .map_err(|err| anyhow!("get_context(\"2d\") failed: {err:?}"))? + .ok_or_else(|| anyhow!("canvas has no 2d context"))? + .dyn_into::() + .map_err(|_| anyhow!("2d context is not a CanvasRenderingContext2d")) + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = canvas; + unimplemented!("web canvas is only available on wasm32") } } diff --git a/crates/ironrdp-web/src/image.rs b/crates/ironrdp-web/src/image.rs index 13ac3fedbf..2df63efcf4 100644 --- a/crates/ironrdp-web/src/image.rs +++ b/crates/ironrdp-web/src/image.rs @@ -2,18 +2,29 @@ use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _}; use ironrdp::session::image::DecodedImage; - -pub(crate) fn extract_partial_image(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec) { +use ironrdp_core::WriteBuf; + +/// Copies the dirty `region` into `buffer` from its current cursor (clear it between regions). +/// The returned rect may be wider than `region`: the whole-rows path widens to full image width. +pub(crate) fn extract_partial_image( + image: &DecodedImage, + region: InclusiveRectangle, + buffer: &mut WriteBuf, +) -> InclusiveRectangle { // PERF: needs actual benchmark to find a better heuristic if region.height() > 64 || region.width() > 512 { - extract_whole_rows(image, region) + extract_whole_rows(image, region, buffer) } else { - extract_smallest_rectangle(image, region) + extract_smallest_rectangle(image, region, buffer) } } // Faster for low-height and smaller images -fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec) { +fn extract_smallest_rectangle( + image: &DecodedImage, + region: InclusiveRectangle, + buffer: &mut WriteBuf, +) -> InclusiveRectangle { let pixel_size = usize::from(image.pixel_format().bytes_per_pixel()); let image_width = usize::from(image.width()); @@ -26,7 +37,7 @@ fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle) let region_stride = region_width * pixel_size; let dst_buf_size = region_width * region_height * pixel_size; - let mut dst = vec![0; dst_buf_size]; + let dst = buffer.unfilled_to(dst_buf_size); let src = image.data(); @@ -42,11 +53,13 @@ fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle) target_slice.copy_from_slice(src_slice); } - (region, dst) + buffer.advance(dst_buf_size); + + region } // Faster for high-height and bigger images -fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec) { +fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle, buffer: &mut WriteBuf) -> InclusiveRectangle { let pixel_size = usize::from(image.pixel_format().bytes_per_pixel()); let image_width = usize::from(image.width()); @@ -59,15 +72,15 @@ fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle) -> (Incl let src_begin = region_top * image_stride; let src_end = (region_bottom + 1) * image_stride; + let len = src_end - src_begin; - let dst = src[src_begin..src_end].to_vec(); + buffer.unfilled_to(len).copy_from_slice(&src[src_begin..src_end]); + buffer.advance(len); - let wider_region = InclusiveRectangle { + InclusiveRectangle { left: 0, top: region.top, right: image.width() - 1, bottom: region.bottom, - }; - - (wider_region, dst) + } } diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index ba1c31e5c5..65fc33441f 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -657,6 +657,9 @@ impl iron_remote_desktop::Session for Session { let mut requested_resize = None; + // Reused across frames so per-region extraction doesn't allocate on every draw. + let mut draw_buffer = WriteBuf::new(); + let mut active_stage = ActiveStage::new(connection_result); // Timer interval for driving clipboard lock timeouts (5 second interval) @@ -875,9 +878,10 @@ impl iron_remote_desktop::Session for Session { .context("Send frame to writer task")?; } ActiveStageOutput::GraphicsUpdate(region) => { - // PERF: some copies and conversion could be optimized - let (region, buffer) = extract_partial_image(&image, region); - gui.draw(&buffer, region).context("draw updated region")?; + let region = extract_partial_image(&image, region, &mut draw_buffer); + gui.draw(draw_buffer.filled_mut(), region) + .context("draw updated region")?; + draw_buffer.clear(); } ActiveStageOutput::PointerDefault => { self.set_cursor_style(CursorStyle::Default)?; @@ -987,8 +991,6 @@ impl iron_remote_desktop::Session for Session { // We need to perform resize after receiving the Deactivate All PDU, because there may be frames // with the previous dimensions arriving between the resize request and this message. if let Some((width, height)) = requested_resize { - self.render_canvas.set_width(width.get()); - self.render_canvas.set_height(height.get()); gui.resize(width, height); requested_resize = None; } From 8f76260ea753f546a577ad7a1176a5740adc94cf Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Thu, 25 Jun 2026 22:52:54 -0500 Subject: [PATCH 290/325] feat(tls): expose negotiated TLS version and cipher suite (#1384) Adds a backend-neutral way to query the TLS parameters negotiated for an established ironrdp-tls::TlsStream, enabling downstream diagnostic tooling to report the negotiated protocol version and cipher suite alongside the existing certificate information. --- crates/ironrdp-tls/src/lib.rs | 16 +++++++++++++++- crates/ironrdp-tls/src/native_tls.rs | 5 +++++ crates/ironrdp-tls/src/rustls.rs | 11 +++++++++++ crates/ironrdp-tls/src/stub.rs | 5 +++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-tls/src/lib.rs b/crates/ironrdp-tls/src/lib.rs index cfefd6f156..6da259fd53 100644 --- a/crates/ironrdp-tls/src/lib.rs +++ b/crates/ironrdp-tls/src/lib.rs @@ -23,7 +23,21 @@ compile_error!("a TLS backend must be selected by enabling a single feature out // The whole public API of this crate. #[cfg(any(feature = "stub", feature = "native-tls", feature = "rustls"))] -pub use impl_::{TlsStream, upgrade}; +pub use impl_::{TlsStream, negotiated, upgrade}; + +/// TLS parameters negotiated during the handshake, to the extent the active +/// backend exposes them. +/// +/// The `rustls` backend reports both fields. The `native-tls` and `stub` +/// backends cannot introspect the negotiated parameters, so both are `None` +/// there. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NegotiatedTls { + /// Negotiated protocol version, e.g. `"TLSv1_3"`. + pub version: Option, + /// Negotiated cipher suite, e.g. `"TLS13_AES_256_GCM_SHA384"`. + pub cipher_suite: Option, +} pub fn extract_tls_server_public_key(cert: &x509_cert::Certificate) -> Option<&[u8]> { cert.tbs_certificate diff --git a/crates/ironrdp-tls/src/native_tls.rs b/crates/ironrdp-tls/src/native_tls.rs index f3b7d0d0e2..578178b33f 100644 --- a/crates/ironrdp-tls/src/native_tls.rs +++ b/crates/ironrdp-tls/src/native_tls.rs @@ -39,3 +39,8 @@ where Ok((tls_stream, tls_cert)) } + +/// The `native-tls` backend does not expose the negotiated version or cipher. +pub fn negotiated(_stream: &TlsStream) -> crate::NegotiatedTls { + crate::NegotiatedTls::default() +} diff --git a/crates/ironrdp-tls/src/rustls.rs b/crates/ironrdp-tls/src/rustls.rs index 8f2cfb91b3..29ac8643ba 100644 --- a/crates/ironrdp-tls/src/rustls.rs +++ b/crates/ironrdp-tls/src/rustls.rs @@ -51,6 +51,17 @@ where Ok((tls_stream, tls_cert)) } +/// Report the TLS version and cipher suite negotiated for `stream`. +pub fn negotiated(stream: &TlsStream) -> crate::NegotiatedTls { + let (_, connection) = stream.get_ref(); + crate::NegotiatedTls { + version: connection.protocol_version().map(|version| format!("{version:?}")), + cipher_suite: connection + .negotiated_cipher_suite() + .map(|suite| format!("{:?}", suite.suite())), + } +} + mod danger { use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use tokio_rustls::rustls::{DigitallySignedStruct, Error, SignatureScheme, pki_types}; diff --git a/crates/ironrdp-tls/src/stub.rs b/crates/ironrdp-tls/src/stub.rs index 484979d61d..500066450d 100644 --- a/crates/ironrdp-tls/src/stub.rs +++ b/crates/ironrdp-tls/src/stub.rs @@ -37,3 +37,8 @@ where let _ = (stream, server_name); Err(io::Error::other("no TLS backend enabled for this build")) } + +/// The stub backend performs no handshake and reports nothing. +pub fn negotiated(_stream: &TlsStream) -> crate::NegotiatedTls { + crate::NegotiatedTls::default() +} From 0a461b5d366677fd2f0f664a4f0074e4ab697c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:24:36 +0900 Subject: [PATCH 291/325] build(deps): align sspi and picky dependencies (#1385) --- Cargo.lock | 146 +++++++++++++--------------- crates/ironrdp-connector/Cargo.toml | 2 +- crates/ironrdp-mstsgu/Cargo.toml | 2 +- crates/ironrdp-web/Cargo.toml | 2 +- 4 files changed, 73 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4904a28f2..8042674c48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,9 +58,9 @@ dependencies = [ [[package]] name = "aes-gcm" -version = "0.11.0-rc.3" +version = "0.11.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972" +checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8" dependencies = [ "aead", "aes", @@ -1040,9 +1040,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a0d26b245348befa0c121944541476763dcc46ede886c88f9d12e1697d27c3" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", @@ -1155,12 +1155,12 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "curve25519-dalek" -version = "5.0.0-pre.6" +version = "5.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto", @@ -1473,9 +1473,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" -version = "0.17.0-rc.17" +version = "0.17.0-rc.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4bf51f0534ed6e59a0f2f26272b64ba55c470133f8424c2adfd1c4d59d9988" +checksum = "b7c72d1455753a703ad4b90ed2a759f2bc4562024a303176439cf6e593b5ade4" dependencies = [ "der 0.8.0", "digest 0.11.3", @@ -1488,19 +1488,18 @@ dependencies = [ [[package]] name = "ed25519" -version = "3.0.0-rc.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", "signature", ] [[package]] name = "ed25519-dalek" -version = "3.0.0-pre.6" +version = "3.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" +checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849" dependencies = [ "curve25519-dalek", "ed25519", @@ -1518,22 +1517,21 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" -version = "0.14.0-rc.31" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b148a81cede8f4023248f980cffdf7611c46f2add469c6980e815b7c5b764ba5" +checksum = "3273f1195b6f6253ebda493d6742c8baa9b26a291674cd96d92a0f09e90e9b46" dependencies = [ "base16ct", "crypto-bigint", "crypto-common 0.2.2", "digest 0.11.3", + "ff", + "group", "hkdf", "hybrid-array", - "once_cell", "pem-rfc7468 1.0.0", "pkcs8", "rand_core 0.10.1", - "rustcrypto-ff", - "rustcrypto-group", "sec1", "subtle", "zeroize", @@ -1596,11 +1594,11 @@ dependencies = [ [[package]] name = "ff" -version = "0.14.0-pre.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d42dd26f5790eda47c1a2158ea4120e32c35ddc9a7743c98a292accc01b54ef3" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", "subtle", ] @@ -1953,12 +1951,12 @@ dependencies = [ [[package]] name = "group" -version = "0.14.0-pre.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ff6a0b2dd4b981b1ae9e3e6830ab146771f3660d31d57bafd9018805a91b0f1" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.9.5", + "rand_core 0.10.1", "subtle", ] @@ -4042,9 +4040,9 @@ dependencies = [ [[package]] name = "p256" -version = "0.14.0-rc.9" +version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b97e3bf0465157ae90975ff52dbeb1362ba618924878c9f74c25baa27a65f9a" +checksum = "c855a8d2ffd346aa03122626f22e96e3aa75e3bfe64e6bf6cb82f71821ed6ae7" dependencies = [ "ecdsa", "elliptic-curve", @@ -4055,9 +4053,9 @@ dependencies = [ [[package]] name = "p384" -version = "0.14.0-rc.9" +version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437f30ebcb1e16ff48acead5f08bd69fbcdbc82421687bb48af5c315a0bfab03" +checksum = "62941b68907ddf996ac20f0debf700c236ccc3d874637731a93c631129ca042f" dependencies = [ "ecdsa", "elliptic-curve", @@ -4069,9 +4067,9 @@ dependencies = [ [[package]] name = "p521" -version = "0.14.0-rc.9" +version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9fd792bab86ecf6249561752fb5a413511f999887107dd054bbda5143743d7" +checksum = "0dd6f2fe6e76c8d5e8828e92aafa463777d1e72e70b78acc724214757e92479a" dependencies = [ "base16ct", "ecdsa", @@ -4122,9 +4120,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbkdf2" -version = "0.13.0-rc.10" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f24f3eb2f4471b1730d59e4b730b747939960a8c7eb0c33c5a9076f2d3dddea" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest 0.11.3", "hmac", @@ -4156,11 +4154,10 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "picky" -version = "7.0.0-rc.23" +version = "7.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8b243c0a8e59483c7b0f746aed1c1719245692a92e9a35693f9edb88a0b712" +checksum = "c1ae9cd78eb1d61be4790713d28368cf71c844218fcd91542768805423f02666" dependencies = [ - "aead", "aes", "aes-gcm", "aes-kw", @@ -4173,11 +4170,7 @@ dependencies = [ "des", "digest 0.11.3", "ecdsa", - "ed25519", "ed25519-dalek", - "elliptic-curve", - "ff", - "group", "hex", "hmac", "http", @@ -4191,13 +4184,10 @@ dependencies = [ "picky-asn1-der", "picky-asn1-x509", "pkcs1 0.8.0-rc.4", - "pkcs8", - "primefield", "primeorder", "rand 0.10.1", "rand_core 0.10.1", "rc2", - "rfc6979", "rsa", "rustcrypto-ff", "rustcrypto-ff_derive", @@ -4207,7 +4197,6 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sha3", - "signature", "thiserror 2.0.18", "x25519-dalek", "zeroize", @@ -4255,9 +4244,9 @@ dependencies = [ [[package]] name = "picky-krb" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43602452fdea9ee3fa4141a918c3659bc43d0277143e4ee06be89e26c22e8676" +checksum = "2d188f3192356068dbdba54bddbca6fd0f7a09565d3861eeb8efe1ab77ae8e97" dependencies = [ "aes", "block-padding", @@ -4341,9 +4330,9 @@ dependencies = [ [[package]] name = "pkcs8" -version = "0.11.0-rc.11" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der 0.8.0", "spki 0.8.0", @@ -4482,25 +4471,28 @@ dependencies = [ [[package]] name = "primefield" -version = "0.14.0-rc.9" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b52e6ee42db392378a95622b463c9740631171d1efce43fa445a569c1600cb6" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ "crypto-bigint", "crypto-common 0.2.2", + "ff", "rand_core 0.10.1", - "rustcrypto-ff", "subtle", "zeroize", ] [[package]] name = "primeorder" -version = "0.14.0-rc.9" +version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0556580e42c19833f5d232aca11a7687a503ee41f937b54f5ae1d50fc2a6a36a" +checksum = "4e56e6d67fdf5744e9e245ae571450fe584b91f5af261d0e40163b618e53a1f6" dependencies = [ "elliptic-curve", + "once_cell", + "primefield", + "serdect", ] [[package]] @@ -4895,12 +4887,12 @@ dependencies = [ [[package]] name = "rfc6979" -version = "0.5.0-rc.5" +version = "0.6.0-pre.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a3127ee32baec36af75b4107082d9bd823501ec14a4e016be4b6b37faa74ae" +checksum = "9935425142ac6e252364413291d96c8bc9898d0876a801824c7af4eae397b689" dependencies = [ + "ctutils", "hmac", - "subtle", ] [[package]] @@ -4928,9 +4920,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.10.0-rc.17" +version = "0.10.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ed3e93fc7e473e464b9726f4759659e72bc8665e4b8ea227547024f416d905" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ "const-oid 0.10.2", "crypto-bigint", @@ -5378,12 +5370,13 @@ dependencies = [ [[package]] name = "sha3" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", "keccak", + "sponge-cursor", ] [[package]] @@ -5434,9 +5427,9 @@ dependencies = [ [[package]] name = "signature" -version = "3.0.0-rc.10" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ "digest 0.11.3", "rand_core 0.10.1", @@ -5581,11 +5574,17 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sspi" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db83308ba07f6c54141f7e34a167353f81250fe8ccab87e90c323f4390b0fb0" +checksum = "15294fb005e36e0b0871d8fc0a4f6aac19f9f5440baee229a9a2b2d7de5ed484" dependencies = [ "async-dnssd", "async-recursion", @@ -5598,10 +5597,8 @@ dependencies = [ "cryptoki", "curve25519-dalek", "ed25519-dalek", - "ff", "futures", "getrandom 0.3.4", - "group", "hmac", "md-5 0.11.0", "md4", @@ -5617,9 +5614,7 @@ dependencies = [ "picky-asn1-x509", "picky-krb", "pkcs1 0.8.0-rc.4", - "pkcs8", "portpicker", - "primefield", "primeorder", "rand 0.10.1", "rand_core 0.10.1", @@ -5633,7 +5628,6 @@ dependencies = [ "serde", "sha1 0.11.0", "sha2 0.11.0", - "signature", "time", "tokio", "tracing", @@ -6324,11 +6318,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -7165,9 +7159,9 @@ dependencies = [ [[package]] name = "winscard" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1210bde4c851460210856b10dbbbad824f9e1e635794f44cf2ce552972521e44" +checksum = "12dafb3c1468d0a3f5440e21e51614b53d1fdc62c9f82cc861c447906d09c69a" dependencies = [ "bitflags 2.12.1", "crypto-bigint", @@ -7329,9 +7323,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "x25519-dalek" -version = "3.0.0-pre.6" +version = "3.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d5d6ff67acd3945b933e592bfa7143db4fcbb2f871754b6b9fbd7847fc5aea" +checksum = "eee64e8620caa64914d669b1f68f858aaff54e2d0f9ad3b30a613b58a1baa83e" dependencies = [ "curve25519-dalek", "rand_core 0.10.1", diff --git a/crates/ironrdp-connector/Cargo.toml b/crates/ironrdp-connector/Cargo.toml index 82776e9c56..5feef2a4ef 100644 --- a/crates/ironrdp-connector/Cargo.toml +++ b/crates/ironrdp-connector/Cargo.toml @@ -32,7 +32,7 @@ rand = { version = "0.9", features = ["std"] } # TODO: dependency injection? tracing = { version = "0.1", features = ["log"] } picky-asn1-der = "0.5" picky-asn1-x509 = "0.15" -picky = "=7.0.0-rc.23" # FIXME: We are pinning with = because the candidate version number counts as the minor number by Cargo, and will be automatically bumped in the Cargo.lock. +picky = "=7.0.0-rc.25" # FIXME: We are pinning with = because the candidate version number counts as the minor number by Cargo, and will be automatically bumped in the Cargo.lock. [lints] workspace = true diff --git a/crates/ironrdp-mstsgu/Cargo.toml b/crates/ironrdp-mstsgu/Cargo.toml index 2fb0cd53d7..3ffa8667a7 100644 --- a/crates/ironrdp-mstsgu/Cargo.toml +++ b/crates/ironrdp-mstsgu/Cargo.toml @@ -36,7 +36,7 @@ log = "0.4" tokio-tungstenite = { version = "0.29" } tokio-util = { version = "0.7" } tokio = { version = "1.52", features = ["macros", "rt"] } -uuid = { version = ">=1.16, <1.21", features = ["v4"] } # Pinned below 1.21: uuid 1.21+ needs getrandom 0.4 -> rand_core 0.10 (stable), which conflicts with picky rc.22's pinned rand_core = "=0.10.0-rc-3". Remove this pin when picky ships with stable RustCrypto deps. +uuid = { version = "1", features = ["v4"] } [lints] workspace = true diff --git a/crates/ironrdp-web/Cargo.toml b/crates/ironrdp-web/Cargo.toml index 8771c4b29c..716181f3ee 100644 --- a/crates/ironrdp-web/Cargo.toml +++ b/crates/ironrdp-web/Cargo.toml @@ -71,7 +71,7 @@ rgb = "0.8" # Enable WebAssembly support for a few crates getrandom2 = { package = "getrandom", version = "0.2", features = ["js"] } getrandom = { version = "0.3", features = ["wasm_js"] } -getrandom4 = { package = "getrandom", version = "0.4.0-rc.0", features = ["wasm_js"] } # picky rc.22 transitive dep +getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] } # sspi/picky transitive dep chrono = { version = "0.4", features = ["wasmbind"] } time = { version = "0.3", features = ["wasm-bindgen"] } From 3f96d0029d37d3cee84b419bbf4d53b5519e385d Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Fri, 26 Jun 2026 10:26:13 -0500 Subject: [PATCH 292/325] fix(pdu): set COMPRESSION_USED on the FastPath update header when compressed (#1382) --- .../src/basic_output/fast_path/mod.rs | 6 +++++- .../src/basic_output/fast_path/tests.rs | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-pdu/src/basic_output/fast_path/mod.rs b/crates/ironrdp-pdu/src/basic_output/fast_path/mod.rs index 34189405fe..a604f2a684 100644 --- a/crates/ironrdp-pdu/src/basic_output/fast_path/mod.rs +++ b/crates/ironrdp-pdu/src/basic_output/fast_path/mod.rs @@ -141,11 +141,15 @@ impl Encode for FastPathUpdatePdu<'_> { let mut header = 0u8; header.set_bits(0..4, self.update_code.as_u8()); header.set_bits(4..6, self.fragmentation.as_u8()); + if self.compression_flags.is_some() { + // The COMPRESSION_USED bit must be set on the header byte before it + // is written, so the decoder knows a compression flags byte follows. + header.set_bits(6..8, Compression::COMPRESSION_USED.bits()); + } dst.write_u8(header); if self.compression_flags.is_some() { - header.set_bits(6..8, Compression::COMPRESSION_USED.bits()); let compression_flags_with_type = self.compression_flags.map(|f| f.bits()).unwrap_or(0) | self.compression_type.map_or(0, |f| f.as_u8()); dst.write_u8(compression_flags_with_type); diff --git a/crates/ironrdp-pdu/src/basic_output/fast_path/tests.rs b/crates/ironrdp-pdu/src/basic_output/fast_path/tests.rs index 2a714ef6ca..0201ddcae5 100644 --- a/crates/ironrdp-pdu/src/basic_output/fast_path/tests.rs +++ b/crates/ironrdp-pdu/src/basic_output/fast_path/tests.rs @@ -196,3 +196,23 @@ fn palette_decode_with_code_returns_palette_variant() { other => panic!("Expected Palette variant, got: {other:?}"), } } + +#[test] +fn compressed_update_round_trips() { + // The encoder must set the COMPRESSION_USED bit on the update header when + // compression flags are present, otherwise the decoder does not consume the + // trailing compression flags byte and misreads the data length. + let data = [0xAAu8; 8]; + let pdu = FastPathUpdatePdu { + fragmentation: Fragmentation::Single, + update_code: UpdateCode::SurfaceCommands, + compression_flags: Some(CompressionFlags::COMPRESSED), + compression_type: Some(CompressionType::K64), + data: &data, + }; + + let mut buffer = vec![0u8; pdu.size()]; + encode(&pdu, buffer.as_mut_slice()).unwrap(); + + assert_eq!(pdu, decode::>(&buffer).unwrap()); +} From 9d206a3da7756fdf8ed13087592eb2298d73e0cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 26 Jun 2026 14:38:47 -0400 Subject: [PATCH 293/325] build: add iOS arm64 simulator NuGet RID Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/nuget-publish.yml | 112 ++++++++++-------- .../Devolutions.IronRdp.Build.iOS.props | 14 ++- .../Devolutions.IronRdp.csproj | 6 +- .../Devolutions.IronRdp.iOS.props | 15 ++- 4 files changed, 91 insertions(+), 56 deletions(-) diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 4317533f75..53907c47cc 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -53,7 +53,7 @@ jobs: strategy: fail-fast: false matrix: - os: [win, osx, linux, ios, android] + os: [win, osx, linux, ios, iossimulator, android] arch: [x86, x64, arm, arm64] include: - os: win @@ -64,6 +64,8 @@ jobs: runner: ubuntu-22.04 - os: ios runner: macos-14 + - os: iossimulator + runner: macos-14 - os: android runner: ubuntu-22.04 exclude: @@ -83,6 +85,14 @@ jobs: os: linux - arch: x86 os: ios + - arch: x64 + os: ios + - arch: arm + os: iossimulator + - arch: x86 + os: iossimulator + - arch: x64 + os: iossimulator steps: - name: Checkout ${{ github.repository }} @@ -104,6 +114,14 @@ jobs: run: Write-Output "IPHONEOS_DEPLOYMENT_TARGET=12.1" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append shell: pwsh + - name: Configure iOS simulator deployement target + if: ${{ matrix.os == 'iossimulator' }} + run: | + Write-Output "IPHONESIMULATOR_DEPLOYMENT_TARGET=12.1" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append + $SimulatorSdk = xcrun --sdk iphonesimulator --show-sdk-path + Write-Output "SDKROOT=$SimulatorSdk" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append + shell: pwsh + - name: Update runner if: ${{ matrix.os == 'linux' }} run: sudo apt update @@ -129,7 +147,7 @@ jobs: # No pre-generated bindings for Android and iOS. # https://aws.github.io/aws-lc-rs/platform_support.html#pre-generated-bindings - name: Install bindgen-cli for aws-lc-sys - if: ${{ matrix.os == 'android' || matrix.os == 'ios' }} + if: ${{ matrix.os == 'android' || matrix.os == 'ios' || matrix.os == 'iossimulator' }} run: cargo install --force --locked bindgen-cli # For aws-lc-sys. Error returned otherwise: @@ -160,11 +178,11 @@ jobs: $RustArch = @{'x64'='x86_64';'arm64'='aarch64'; 'x86'='i686';'arm'='armv7'}[$DotNetArch] $RustPlatform = @{'win'='pc-windows-msvc'; - 'osx'='apple-darwin';'ios'='apple-ios'; + 'osx'='apple-darwin';'ios'='apple-ios';'iossimulator'='apple-ios-sim'; 'linux'='unknown-linux-gnu';'android'='linux-android'}[$DotNetOs] - $LibPrefix = @{'win'='';'osx'='lib';'ios'='lib'; + $LibPrefix = @{'win'='';'osx'='lib';'ios'='lib';'iossimulator'='lib'; 'linux'='lib';'android'='lib'}[$DotNetOs] - $LibSuffix = @{'win'='.dll';'osx'='.dylib';'ios'='.dylib'; + $LibSuffix = @{'win'='.dll';'osx'='.dylib';'ios'='.dylib';'iossimulator'='.dylib'; 'linux'='.so';'android'='.so'}[$DotNetOs] $RustTarget = "$RustArch-$RustPlatform" @@ -211,52 +229,13 @@ jobs: Copy-Item $OutputLibrary $(Join-Path $OutputPath $RenamedLibraryName) shell: pwsh - - name: Upload native components - uses: actions/upload-artifact@v7 - with: - name: ironrdp-${{matrix.os}}-${{matrix.arch}} - path: dependencies/runtimes/${{matrix.os}}-${{matrix.arch}} - - build-universal: - name: Universal build - needs: [preflight, build-native] - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - os: [ osx, ios ] - - steps: - - name: Checkout ${{ github.repository }} - uses: actions/checkout@v6 - - - name: Setup CCTools - uses: Devolutions/actions-public/setup-cctools@v1 - - - name: Download native components - uses: actions/download-artifact@v8 - with: - path: dependencies/runtimes - - - name: Lipo native components - run: | - Set-Location "dependencies/runtimes" - # No RID for universal binaries, see: https://github.com/dotnet/runtime/issues/53156 - $OutputPath = Join-Path "${{ matrix.os }}-universal" "native" - New-Item -ItemType Directory -Path $OutputPath | Out-Null - $Libraries = Get-ChildItem -Recurse -Path "ironrdp-${{ matrix.os }}-*" -Filter "*.dylib" | Foreach-Object { $_.FullName } | Select -Unique - $LipoCmd = $(@('lipo', '-create', '-output', (Join-Path -Path $OutputPath -ChildPath "libDevolutionsIronRdp.dylib")) + $Libraries) -Join ' ' - Write-Host $LipoCmd - Invoke-Expression $LipoCmd - shell: pwsh - - - name: Framework - if: ${{ matrix.os == 'ios' }} + - name: Framework (${{matrix.os}}-${{matrix.arch}}) + if: ${{ matrix.os == 'ios' || matrix.os == 'iossimulator' }} run: | $Version = '${{ needs.preflight.outputs.project-version }}' $ShortVersion = '${{ needs.preflight.outputs.package-version }}' $BundleName = "libDevolutionsIronRdp" - $RuntimesDir = Join-Path "dependencies" "runtimes" "ios-universal" "native" + $RuntimesDir = Join-Path "dependencies" "runtimes" "${{ matrix.os }}-${{ matrix.arch }}" "native" $FrameworkDir = Join-Path "$RuntimesDir" "$BundleName.framework" New-Item -Path $FrameworkDir -ItemType "directory" -Force $FrameworkExecutable = Join-Path $FrameworkDir $BundleName @@ -295,6 +274,45 @@ jobs: ((Get-Content -Path (Join-Path $FrameworkDir "Info.plist") -Raw) -Replace 'PropertyList-1.0.dtd"\[\]', 'PropertyList-1.0.dtd"') | Set-Content -Path (Join-Path $FrameworkDir "Info.plist") shell: pwsh + - name: Upload native components + uses: actions/upload-artifact@v7 + with: + name: ironrdp-${{matrix.os}}-${{matrix.arch}} + path: dependencies/runtimes/${{matrix.os}}-${{matrix.arch}} + + build-universal: + name: Universal build + needs: [preflight, build-native] + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + os: [ osx ] + + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v6 + + - name: Setup CCTools + uses: Devolutions/actions-public/setup-cctools@v1 + + - name: Download native components + uses: actions/download-artifact@v8 + with: + path: dependencies/runtimes + + - name: Lipo native components + run: | + Set-Location "dependencies/runtimes" + # No RID for universal binaries, see: https://github.com/dotnet/runtime/issues/53156 + $OutputPath = Join-Path "${{ matrix.os }}-universal" "native" + New-Item -ItemType Directory -Path $OutputPath | Out-Null + $Libraries = Get-ChildItem -Recurse -Path "ironrdp-${{ matrix.os }}-*" -Filter "*.dylib" | Foreach-Object { $_.FullName } | Select -Unique + $LipoCmd = $(@('lipo', '-create', '-output', (Join-Path -Path $OutputPath -ChildPath "libDevolutionsIronRdp.dylib")) + $Libraries) -Join ' ' + Write-Host $LipoCmd + Invoke-Expression $LipoCmd + shell: pwsh + - name: Upload native components uses: actions/upload-artifact@v7 with: diff --git a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.Build.iOS.props b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.Build.iOS.props index b692fdbbc9..79d86c0257 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.Build.iOS.props +++ b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.Build.iOS.props @@ -4,9 +4,17 @@ 12.1 - - - runtimes/ios/native/ + + + runtimes/ios-arm64/native/ + true + Never + + + + + + runtimes/iossimulator-arm64/native/ true Never diff --git a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj index 38fc2e6573..b9aa05118f 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj +++ b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj @@ -25,10 +25,8 @@ $(RuntimesPath)/android-arm/native/libDevolutionsIronRdp.so $(RuntimesPath)/android-x64/native/libDevolutionsIronRdp.so $(RuntimesPath)/android-x86/native/libDevolutionsIronRdp.so - $(RuntimesPath)/ios-x64/native/libDevolutionsIronRdp.dylib - $(RuntimesPath)/ios-arm64/native/libDevolutionsIronRdp.dylib - $(RuntimesPath)/ios-universal/native/libDevolutionsIronRdp.dylib - $(RuntimesPath)/ios-universal/native/libDevolutionsIronRdp.framework + $(RuntimesPath)/ios-arm64/native/libDevolutionsIronRdp.framework + $(RuntimesPath)/iossimulator-arm64/native/libDevolutionsIronRdp.framework diff --git a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.iOS.props b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.iOS.props index 2831365331..eaa40889cc 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.iOS.props +++ b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.iOS.props @@ -1,7 +1,18 @@ - - + + true + true + + + + + Framework + + + + + Framework From f7e6106e0f293c1e0f8129be82aa2d86737ba92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:06:46 +0900 Subject: [PATCH 294/325] feat(client): gate native backends behind Cargo features (#1338) ironrdp (meta crate): - Added: client, client-all, client-sound, client-clipboard, client-rdpdr, client-smartcard, client-gateway, client-dvc-pipe-proxy, client-dvc-com-plugin, and top-level rustls / native-tls (forwarded to ironrdp-client) - Modified: qoi, qoiz now also gate ironrdp-client's codec ironrdp-client: - Added: sound, clipboard, rdpdr, smartcard, gateway, dvc-pipe-proxy, dvc-com-plugin, all; optional subsystem crates are pulled in only via these features - Modified: default no longer forces rustls; rustls / native-tls stay mutually exclusive (exactly one required), and qoi / qoiz now point at ironrdp-connector / ironrdp-session ironrdp-viewer: - Removed: direct ironrdp-client / backend-crate dependencies and qoi / qoiz features - Modified: rustls / native-tls now forward to ironrdp instead of ironrdp-client; pulls client + client-all from the meta crate --- Cargo.lock | 19 +- crates/ironrdp-client/Cargo.toml | 80 +- crates/ironrdp-client/src/clipboard.rs | 25 + crates/ironrdp-client/src/config.rs | 378 +++++++- crates/ironrdp-client/src/lib.rs | 3 + crates/ironrdp-client/src/rdp.rs | 817 ++++++++++-------- .../tests/config_rdp.rs | 18 +- crates/ironrdp-tls/src/native_tls.rs | 13 +- crates/ironrdp-viewer/Cargo.toml | 13 +- crates/ironrdp-viewer/src/app.rs | 2 +- crates/ironrdp-viewer/src/clipboard.rs | 2 +- crates/ironrdp-viewer/src/config.rs | 101 ++- crates/ironrdp-viewer/src/lib.rs | 1 - crates/ironrdp-viewer/src/main.rs | 60 +- crates/ironrdp/Cargo.toml | 21 +- crates/ironrdp/src/lib.rs | 8 + xtask/src/features.rs | 39 +- 17 files changed, 1073 insertions(+), 527 deletions(-) create mode 100644 crates/ironrdp-client/src/clipboard.rs diff --git a/Cargo.lock b/Cargo.lock index 8042674c48..ce77fe08c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2400,6 +2400,7 @@ dependencies = [ "image", "ironrdp-acceptor", "ironrdp-blocking", + "ironrdp-client", "ironrdp-cliprdr", "ironrdp-cliprdr-native", "ironrdp-connector", @@ -2409,6 +2410,7 @@ dependencies = [ "ironrdp-echo", "ironrdp-graphics", "ironrdp-input", + "ironrdp-mstsgu", "ironrdp-pdu", "ironrdp-rdpdr", "ironrdp-rdpsnd", @@ -2500,19 +2502,29 @@ version = "0.1.0" dependencies = [ "anyhow", "futures-util", - "ironrdp", + "ironrdp-cliprdr", + "ironrdp-cliprdr-native", + "ironrdp-connector", "ironrdp-core", + "ironrdp-displaycontrol", + "ironrdp-dvc", "ironrdp-dvc-com-plugin", "ironrdp-dvc-pipe-proxy", + "ironrdp-echo", + "ironrdp-graphics", "ironrdp-mstsgu", + "ironrdp-pdu", "ironrdp-rdcleanpath", + "ironrdp-rdpdr", + "ironrdp-rdpsnd", "ironrdp-rdpsnd-native", + "ironrdp-session", + "ironrdp-svc", "ironrdp-tls", "ironrdp-tokio", "smallvec", "tokio", "tokio-tungstenite", - "tokio-util", "tracing", "transport", "url", @@ -2998,9 +3010,6 @@ dependencies = [ "inquire", "ironrdp", "ironrdp-cfg", - "ironrdp-client", - "ironrdp-cliprdr-native", - "ironrdp-mstsgu", "ironrdp-propertyset", "ironrdp-rdpfile", "proc-exit", diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 89eb9b97e8..f4cce16c1c 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -19,41 +19,71 @@ doctest = false test = false [features] -default = ["rustls"] -rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots", "ironrdp-mstsgu/rustls"] -native-tls = ["ironrdp-tls/native-tls", "tokio-tungstenite/native-tls", "ironrdp-mstsgu/native-tls"] -qoi = ["ironrdp/qoi"] -qoiz = ["ironrdp/qoiz"] +default = [] -[dependencies] -# Protocols -ironrdp = { path = "../ironrdp", version = "0.16", features = [ - "session", - "input", - "graphics", - "dvc", - "svc", +rustls = [ + "ironrdp-tls/rustls", + "tokio-tungstenite/rustls-tls-native-roots", + "ironrdp-mstsgu?/rustls", +] + +native-tls = [ + "ironrdp-tls/native-tls", + "tokio-tungstenite/native-tls", + "ironrdp-mstsgu?/native-tls", +] + +sound = ["dep:ironrdp-rdpsnd", "dep:ironrdp-rdpsnd-native"] +clipboard = ["dep:ironrdp-cliprdr", "dep:ironrdp-cliprdr-native"] +rdpdr = ["dep:ironrdp-rdpdr"] +smartcard = ["rdpdr"] +gateway = ["dep:ironrdp-mstsgu"] +qoi = ["ironrdp-connector/qoi", "ironrdp-session/qoi"] +qoiz = ["ironrdp-connector/qoiz", "ironrdp-session/qoiz"] +dvc-pipe-proxy = ["dep:ironrdp-dvc-pipe-proxy"] +dvc-com-plugin = ["dep:ironrdp-dvc-com-plugin"] + +all = [ + "sound", + "clipboard", "rdpdr", - "rdpsnd", - "cliprdr", - "displaycontrol", - "connector", - "echo", -] } + "smartcard", + "gateway", + "dvc-pipe-proxy", + "dvc-com-plugin", +] + +[dependencies] +# Protocols (core features always on) ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } -ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.6" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } +ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } +ironrdp-session = { path = "../ironrdp-session", version = "0.10" } +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7" } +ironrdp-echo = { path = "../ironrdp-echo", version = "0.3" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } -ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.9", features = ["reqwest"] } -ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" -ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" +ironrdp-rdcleanpath = { path = "../ironrdp-rdcleanpath" } + +# Optional protocol crates (activated by features above) +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6", optional = true } +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6", optional = true } +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8", optional = true } + +# Optional backend crates (activated by features above) +ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.6", optional = true } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6", optional = true } +ironrdp-mstsgu = { path = "../ironrdp-mstsgu", optional = true } +ironrdp-dvc-pipe-proxy = { path = "../ironrdp-dvc-pipe-proxy", optional = true } # Logging tracing = { version = "0.1", features = ["log"] } # Async, futures tokio = { version = "1", features = ["macros", "net", "io-util", "sync", "rt", "time"] } -tokio-util = { version = "0.7" } tokio-tungstenite = "0.29" transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" } futures-util = { version = "0.3", features = ["sink"] } @@ -65,7 +95,7 @@ url = "2" x509-cert = { version = "0.2", default-features = false, features = ["std"] } [target.'cfg(windows)'.dependencies] -ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin" } +ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin", optional = true } [lints] workspace = true diff --git a/crates/ironrdp-client/src/clipboard.rs b/crates/ironrdp-client/src/clipboard.rs new file mode 100644 index 0000000000..cfa2c9cd11 --- /dev/null +++ b/crates/ironrdp-client/src/clipboard.rs @@ -0,0 +1,25 @@ +use ironrdp_cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; +use tokio::sync::mpsc; +use tracing::error; + +use crate::rdp::RdpInputEvent; + +/// Shim that forwards CLIPRDR events into the `RdpInputEvent` channel. +#[derive(Clone, Debug)] +pub(crate) struct ClientClipboardMessageProxy { + tx: mpsc::UnboundedSender, +} + +impl ClientClipboardMessageProxy { + pub(crate) fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } +} + +impl ClipboardMessageProxy for ClientClipboardMessageProxy { + fn send_clipboard_message(&self, message: ClipboardMessage) { + if self.tx.send(RdpInputEvent::Clipboard(message)).is_err() { + error!("Failed to send clipboard message; receiver is closed"); + } + } +} diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 4bcd595001..2bc41d953b 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -1,49 +1,107 @@ use core::fmt; use core::str::FromStr; use core::time::Duration; -#[cfg(windows)] +#[cfg(all(windows, feature = "dvc-com-plugin"))] use std::path::PathBuf; +use std::sync::Arc; use anyhow::Context as _; -use ironrdp::connector; -use ironrdp_mstsgu::GwConnectTarget; use url::Url; +// ── Extension registry ──────────────────────────────────────────────────────── + +type StaticChannelFn = Arc; +type DvcChannelFn = Arc; + +/// Private registry of user-supplied static and dynamic virtual channel factories. +/// +/// Cloneable via `Arc`; the factory closures are shared across reconnects. +#[derive(Default)] +pub(crate) struct ExtensionRegistry { + pub(crate) static_channels: Vec, + pub(crate) dvc_channels: Vec, +} + +impl Clone for ExtensionRegistry { + fn clone(&self) -> Self { + Self { + static_channels: self.static_channels.clone(), + dvc_channels: self.dvc_channels.clone(), + } + } +} + +impl fmt::Debug for ExtensionRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ExtensionRegistry") + .field("static_channels", &self.static_channels.len()) + .field("dvc_channels", &self.dvc_channels.len()) + .finish() + } +} + +// ── Public configuration types ──────────────────────────────────────────────── + /// Fully resolved client configuration. /// -/// This is the typed surface consumed by [`crate::rdp::RdpClient`]. Producing a `Config` -/// from CLI arguments, `.rdp` files, or interactive prompts is the consumer's responsibility -/// (see the `ironrdp-viewer` crate for a reference CLI front-end). -#[derive(Clone, Debug)] +/// This is the typed surface consumed by [`crate::rdp::RdpClient`]. Build it with +/// [`ConfigBuilder`]; producing a `Config` from CLI arguments, `.rdp` files, or interactive +/// prompts is the consumer's responsibility (see `ironrdp-viewer` for a reference front-end). +#[derive(Clone)] +#[expect( + clippy::partial_pub_fields, + reason = "extensions must stay crate-private because its type ExtensionRegistry is pub(crate)" +)] pub struct Config { - pub log_file: Option, - pub gw: Option, - pub kerberos_config: Option, + pub connector: ironrdp_connector::Config, pub destination: Destination, - pub connector: connector::Config, - pub clipboard_type: ClipboardType, - pub rdcleanpath: Option, + pub transport: Transport, + pub kerberos_config: Option, + pub log_file: Option, pub fake_events_interval: Option, + pub channels: ChannelConfig, - /// DVC channel <-> named pipe proxy configuration. + /// DVC channel ↔ named-pipe proxy configuration. /// - /// Each configured proxy enables IronRDP to connect to DVC channel and create a named pipe - /// server, which will be used for proxying DVC messages to/from user-defined DVC logic - /// implemented as named pipe clients (either in the same process or in a different process). + /// Each entry causes IronRDP to forward that DVC channel's traffic to/from the + /// named pipe, allowing out-of-process DVC logic. + #[cfg(feature = "dvc-pipe-proxy")] pub dvc_pipe_proxies: Vec, /// Paths to DVC client plugin DLLs to load (Windows only). /// - /// Each DLL is loaded via `LoadLibraryW` and its `VirtualChannelGetInstance` export is called - /// to obtain DVC plugin COM objects. Example: `C:\Windows\System32\webauthn.dll`. - #[cfg(windows)] + /// Each DLL is loaded via `LoadLibraryW` and its `VirtualChannelGetInstance` export is + /// called to obtain DVC plugin COM objects. Example: `C:\Windows\System32\webauthn.dll`. + #[cfg(all(windows, feature = "dvc-com-plugin"))] pub dvc_plugins: Vec, + + pub(crate) extensions: ExtensionRegistry, +} + +impl fmt::Debug for Config { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut s = f.debug_struct("Config"); + s.field("connector", &self.connector); + s.field("destination", &self.destination); + s.field("transport", &self.transport); + s.field("kerberos_config", &self.kerberos_config); + s.field("log_file", &self.log_file); + s.field("fake_events_interval", &self.fake_events_interval); + s.field("channels", &self.channels); + #[cfg(feature = "dvc-pipe-proxy")] + s.field("dvc_pipe_proxies", &self.dvc_pipe_proxies); + #[cfg(all(windows, feature = "dvc-com-plugin"))] + s.field("dvc_plugins", &self.dvc_plugins); + s.field("extensions", &self.extensions); + s.finish() + } } /// Resolved clipboard backend selection. /// /// Platform-specific details (e.g., which native clipboard backend to use) are handled -/// internally by the library when `Enable` is selected. +/// internally by the library when [`Enable`](ClipboardType::Enable) is selected. +#[cfg(feature = "clipboard")] #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ClipboardType { /// Enable clipboard redirection (use the best available backend). @@ -54,6 +112,117 @@ pub enum ClipboardType { Stub, } +/// Channel and codec runtime toggles. +/// +/// Each field is only present when the corresponding Cargo feature is enabled. +/// The defaults for all optional fields are `true` (enabled) when the feature is on. +#[derive(Clone, Debug)] +pub struct ChannelConfig { + /// Enable the RDPSND (audio) virtual channel. + #[cfg(feature = "sound")] + pub sound: bool, + + /// Clipboard redirection mode. + #[cfg(feature = "clipboard")] + pub clipboard: ClipboardType, + + /// Device-redirection (RDPDR) configuration. + #[cfg(feature = "rdpdr")] + pub rdpdr: RdpdrConfig, + + /// Enable QOI bitmap codec. + /// + /// When `false`, the QOI codec is removed from `connector.bitmap.codecs` before connecting + /// even if the `qoi` feature is compiled in. + #[cfg(feature = "qoi")] + pub qoi: bool, + + /// Enable QOIZ (QOI with zlib) bitmap codec. + #[cfg(feature = "qoiz")] + pub qoiz: bool, +} + +#[cfg_attr( + not(any(feature = "sound", feature = "clipboard", feature = "qoi", feature = "qoiz")), + expect( + clippy::derivable_impls, + reason = "fields setting non-default values are feature-gated; the impl is only trivially derivable in some feature combinations" + ) +)] +impl Default for ChannelConfig { + fn default() -> Self { + Self { + #[cfg(feature = "sound")] + sound: true, + #[cfg(feature = "clipboard")] + clipboard: ClipboardType::Enable, + #[cfg(feature = "rdpdr")] + rdpdr: RdpdrConfig::default(), + #[cfg(feature = "qoi")] + qoi: true, + #[cfg(feature = "qoiz")] + qoiz: true, + } + } +} + +/// RDPDR (device redirection) runtime configuration. +#[cfg(feature = "rdpdr")] +#[derive(Clone, Debug)] +pub struct RdpdrConfig { + /// Enable device redirection at all. + pub enabled: bool, + + /// Enable smart-card redirection within RDPDR. + #[cfg(feature = "smartcard")] + pub smartcard: bool, +} + +#[cfg(feature = "rdpdr")] +impl Default for RdpdrConfig { + fn default() -> Self { + Self { + enabled: true, + #[cfg(feature = "smartcard")] + smartcard: true, + } + } +} + +/// Transport selection for the RDP connection. +#[derive(Clone, Debug)] +pub enum Transport { + /// Plain TCP → TLS direct connection to the RDP server. + Direct, + + /// Connect via an RDS gateway (MS-TSGU / MSTSGU). + /// + /// The target RDP server is derived from [`Config::destination`]; the gateway + /// only needs its own endpoint and credentials. + /// + /// NOTE: the destination port is currently not forwarded to the gateway. + /// If `ironrdp-mstsgu` hardcodes port 3389, open a follow-up issue. + #[cfg(feature = "gateway")] + Gateway(GatewayConfig), + + /// Connect via an RDCleanPath proxy (WebSocket-based). + RDCleanPath(RDCleanPathConfig), +} + +/// Credentials and endpoint for an RDS gateway connection. +#[cfg(feature = "gateway")] +#[derive(Clone, Debug)] +pub struct GatewayConfig { + /// Gateway endpoint address (e.g., `"rdg.contoso.com:443"`). + pub endpoint: String, + /// Gateway username. + pub username: String, + /// Gateway password. + pub password: String, +} + +// ── Destination ─────────────────────────────────────────────────────────────── + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Destination { name: String, @@ -130,24 +299,27 @@ impl FromStr for Destination { } } -impl From for connector::ServerName { +impl From for ironrdp_connector::ServerName { fn from(value: Destination) -> Self { Self::new(value.name) } } -impl From<&Destination> for connector::ServerName { +impl From<&Destination> for ironrdp_connector::ServerName { fn from(value: &Destination) -> Self { Self::new(&value.name) } } +// ── RDCleanPath & DVC proxy ─────────────────────────────────────────────────── + #[derive(Clone, Debug)] pub struct RDCleanPathConfig { pub url: Url, pub auth_token: String, } +/// Name-to-pipe mapping for a single DVC proxy channel. #[derive(Clone, Debug)] pub struct DvcProxyInfo { pub channel_name: String, @@ -174,3 +346,163 @@ impl FromStr for DvcProxyInfo { }) } } + +// ── ConfigBuilder ───────────────────────────────────────────────────────────── + +/// Builder for [`Config`]. +/// +/// # Duplicate-channel behaviour +/// +/// * **Static channels** are keyed by the concrete processor `TypeId`; registering two factories +/// with the same concrete type silently shadows the earlier one via +/// [`ironrdp_connector::ClientConnector::attach_static_channel`]. +/// * **DVC channels** are keyed by channel name; duplicate names follow +/// [`ironrdp_dvc::DrdynvcClient`]'s overwrite semantics. +pub struct ConfigBuilder { + config: Config, +} + +impl ConfigBuilder { + pub fn new(connector: ironrdp_connector::Config, destination: Destination) -> Self { + Self { + config: Config { + connector, + destination, + transport: Transport::Direct, + kerberos_config: None, + log_file: None, + fake_events_interval: None, + channels: ChannelConfig::default(), + #[cfg(feature = "dvc-pipe-proxy")] + dvc_pipe_proxies: Vec::new(), + #[cfg(all(windows, feature = "dvc-com-plugin"))] + dvc_plugins: Vec::new(), + extensions: ExtensionRegistry::default(), + }, + } + } + + #[must_use] + pub fn with_transport(mut self, transport: Transport) -> Self { + self.config.transport = transport; + self + } + + #[must_use] + pub fn with_kerberos_config(mut self, cfg: ironrdp_connector::credssp::KerberosConfig) -> Self { + self.config.kerberos_config = Some(cfg); + self + } + + #[must_use] + pub fn with_log_file(mut self, path: impl Into) -> Self { + self.config.log_file = Some(path.into()); + self + } + + #[must_use] + pub fn with_fake_events_interval(mut self, interval: Duration) -> Self { + self.config.fake_events_interval = Some(interval); + self + } + + /// Enable or disable RDPSND (audio) playback. + #[cfg(feature = "sound")] + #[must_use] + pub fn with_sound(mut self, enabled: bool) -> Self { + self.config.channels.sound = enabled; + self + } + + /// Set the CLIPRDR (clipboard) redirection mode. + #[cfg(feature = "clipboard")] + #[must_use] + pub fn with_clipboard(mut self, mode: ClipboardType) -> Self { + self.config.channels.clipboard = mode; + self + } + + /// Enable or disable RDPDR (device redirection). + #[cfg(feature = "rdpdr")] + #[must_use] + pub fn with_rdpdr(mut self, enabled: bool) -> Self { + self.config.channels.rdpdr.enabled = enabled; + self + } + + /// Enable or disable smart-card redirection within RDPDR. + #[cfg(feature = "smartcard")] + #[must_use] + pub fn with_smartcard(mut self, enabled: bool) -> Self { + self.config.channels.rdpdr.smartcard = enabled; + self + } + + /// Enable or disable QOI bitmap codec at runtime. + #[cfg(feature = "qoi")] + #[must_use] + pub fn with_qoi(mut self, enabled: bool) -> Self { + self.config.channels.qoi = enabled; + self + } + + /// Enable or disable QOIZ bitmap codec at runtime. + #[cfg(feature = "qoiz")] + #[must_use] + pub fn with_qoiz(mut self, enabled: bool) -> Self { + self.config.channels.qoiz = enabled; + self + } + + /// Add a DVC pipe proxy channel. + #[cfg(feature = "dvc-pipe-proxy")] + #[must_use] + pub fn with_dvc_pipe_proxy(mut self, info: DvcProxyInfo) -> Self { + self.config.dvc_pipe_proxies.push(info); + self + } + + /// Add a DVC COM plugin DLL path (Windows only). + #[cfg(all(windows, feature = "dvc-com-plugin"))] + #[must_use] + pub fn with_dvc_plugin(mut self, path: impl Into) -> Self { + self.config.dvc_plugins.push(path.into()); + self + } + + /// Register a factory for a user-defined static virtual channel. + /// + /// `factory` is called once per connection attempt to create a fresh channel instance. + /// Duplicate processor types follow `attach_static_channel` overwrite semantics. + #[must_use] + pub fn with_static_channel(mut self, factory: F) -> Self + where + F: Fn() -> P + Send + Sync + 'static, + P: ironrdp_svc::SvcClientProcessor + 'static, + { + let cb: StaticChannelFn = Arc::new(move |connector: &mut ironrdp_connector::ClientConnector| { + connector.attach_static_channel(factory()) + }); + self.config.extensions.static_channels.push(cb); + self + } + + /// Register a factory for a user-defined dynamic virtual channel. + /// + /// `factory` is called once per connection attempt to create a fresh channel instance. + /// Duplicate channel names follow `DrdynvcClient` overwrite semantics. + #[must_use] + pub fn with_dvc(mut self, factory: F) -> Self + where + F: Fn() -> P + Send + Sync + 'static, + P: ironrdp_dvc::DvcProcessor + 'static, + { + let cb: DvcChannelFn = Arc::new(move |drdynvc| drdynvc.attach_dynamic_channel(factory())); + self.config.extensions.dvc_channels.push(cb); + self + } + + pub fn build(self) -> Config { + self.config + } +} diff --git a/crates/ironrdp-client/src/lib.rs b/crates/ironrdp-client/src/lib.rs index 753d2937a9..a567b8b762 100644 --- a/crates/ironrdp-client/src/lib.rs +++ b/crates/ironrdp-client/src/lib.rs @@ -10,4 +10,7 @@ pub mod config; pub mod rdp; +#[cfg(all(windows, feature = "clipboard"))] +mod clipboard; + mod ws; diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 073ddfe504..0cb1534964 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -1,37 +1,49 @@ +use core::net::SocketAddr; use core::num::NonZeroU16; use std::sync::Arc; -use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory}; -use ironrdp::connector::connection_activation::ConnectionActivationState; -use ironrdp::connector::{ConnectionResult, ConnectorResult}; -use ironrdp::displaycontrol::client::DisplayControlClient; -use ironrdp::displaycontrol::pdu::MonitorLayoutEntry; -#[cfg(windows)] -use ironrdp::dvc::DvcProcessor as _; -use ironrdp::echo::client::EchoClient; -use ironrdp::graphics::image_processing::PixelFormat; -use ironrdp::graphics::pointer::DecodedPointer; -use ironrdp::pdu::input::fast_path::FastPathInputEvent; -use ironrdp::pdu::{PduResult, pdu_other_err}; -use ironrdp::session::image::DecodedImage; -use ironrdp::session::{ActiveStage, ActiveStageOutput, GracefulDisconnectReason, SessionResult, fast_path}; -use ironrdp::svc::SvcMessage; -use ironrdp::{cliprdr, connector, rdpdr, rdpsnd, session}; +use ironrdp_connector::connection_activation::ConnectionActivationState; +use ironrdp_connector::{ConnectionResult, ConnectorResult}; use ironrdp_core::WriteBuf; -#[cfg(windows)] -use ironrdp_dvc_com_plugin::load_dvc_plugin; -use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; -use ironrdp_rdpsnd_native::cpal; +use ironrdp_displaycontrol::client::DisplayControlClient; +use ironrdp_displaycontrol::pdu::MonitorLayoutEntry; +#[cfg(all(windows, feature = "dvc-com-plugin"))] +use ironrdp_dvc::DvcProcessor as _; +use ironrdp_echo::client::EchoClient; +use ironrdp_graphics::image_processing::PixelFormat; +use ironrdp_graphics::pointer::DecodedPointer; +use ironrdp_pdu::input::fast_path::FastPathInputEvent; +#[cfg(any(feature = "dvc-pipe-proxy", all(windows, feature = "dvc-com-plugin")))] +use ironrdp_pdu::pdu_other_err; +use ironrdp_session::image::DecodedImage; +use ironrdp_session::{ActiveStage, ActiveStageOutput, GracefulDisconnectReason, SessionResult, fast_path}; +use ironrdp_svc::SvcMessage; use ironrdp_tokio::reqwest::ReqwestNetworkClient; use ironrdp_tokio::{FramedWrite, single_sequence_step_read, split_tokio_framed}; -use rdpdr::NoopRdpdrBackend; use smallvec::SmallVec; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::mpsc; -use tracing::{debug, error, info, trace, warn}; +#[cfg(any(feature = "clipboard", all(windows, feature = "dvc-com-plugin")))] +use tracing::error; +#[cfg(feature = "clipboard")] +use tracing::warn; +use tracing::{debug, info, trace}; + +#[cfg(feature = "clipboard")] +use crate::config::ClipboardType; +#[cfg(feature = "clipboard")] +use ironrdp_cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory}; +#[cfg(all(windows, feature = "dvc-com-plugin"))] +use ironrdp_dvc_com_plugin::load_dvc_plugin; +#[cfg(feature = "dvc-pipe-proxy")] +use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; +#[cfg(feature = "sound")] +use ironrdp_rdpsnd_native::cpal; + +use crate::config::{Config, RDCleanPathConfig, Transport}; -use crate::config::{Config, RDCleanPathConfig}; +// ── Public event types ──────────────────────────────────────────────────────── #[derive(Debug)] pub enum RdpOutputEvent { @@ -40,7 +52,7 @@ pub enum RdpOutputEvent { width: NonZeroU16, height: NonZeroU16, }, - ConnectionFailure(connector::ConnectorError), + ConnectionFailure(ironrdp_connector::ConnectorError), PointerDefault, PointerHidden, PointerPosition { @@ -57,11 +69,12 @@ pub enum RdpInputEvent { width: u16, height: u16, scale_factor: u32, - /// The physical size of the display in millimeters (width, height). + /// Physical display size in millimetres (width, height). physical_size: Option<(u32, u32)>, }, FastPath(SmallVec<[FastPathInputEvent; 2]>), Close, + #[cfg(feature = "clipboard")] Clipboard(ClipboardMessage), SendDvcMessages { channel_id: u32, @@ -69,85 +82,149 @@ pub enum RdpInputEvent { }, } -impl RdpInputEvent { - pub fn create_channel() -> (mpsc::UnboundedSender, mpsc::UnboundedReceiver) { - mpsc::unbounded_channel() - } -} +// ── RdpClient ───────────────────────────────────────────────────────────────── -pub struct DvcPipeProxyFactory { - rdp_input_sender: mpsc::UnboundedSender, +pub struct RdpClient { + config: Config, + output_event_sender: mpsc::Sender, + input_event_sender: mpsc::UnboundedSender, + input_event_receiver: mpsc::UnboundedReceiver, } -impl DvcPipeProxyFactory { - pub fn new(rdp_input_sender: mpsc::UnboundedSender) -> Self { - Self { rdp_input_sender } - } - - pub fn create(&self, channel_name: String, pipe_name: String) -> DvcNamedPipeProxy { - let rdp_input_sender = self.rdp_input_sender.clone(); - - DvcNamedPipeProxy::new(&channel_name, &pipe_name, move |channel_id, messages| { - rdp_input_sender - .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) - .map_err(|_error| pdu_other_err!("send DVC messages to the event loop",))?; - - Ok(()) - }) +impl RdpClient { + pub fn new(config: Config, output_event_sender: mpsc::Sender) -> Self { + let (input_event_sender, input_event_receiver) = mpsc::unbounded_channel(); + Self { + config, + output_event_sender, + input_event_sender, + input_event_receiver, + } } - /// Get a clone of the underlying RDP input event sender. + /// Return a clone of the input-event sender for injecting keyboard, mouse, and clipboard + /// events from the GUI thread. pub fn input_sender(&self) -> mpsc::UnboundedSender { - self.rdp_input_sender.clone() + self.input_event_sender.clone() } -} -pub type WriteDvcMessageFn = Box PduResult<()> + Send + 'static>; + pub async fn run(mut self) { + // ── Clipboard initialisation (compile-time gated) ───────────────────── + // + // On Windows the WinClipboard object must outlive the entire connection loop, so we + // keep it alive via `_win_clipboard`. On non-Windows a StubClipboard backend is used + // and its ownership can be released immediately after the factory is extracted. + #[cfg(all(windows, feature = "clipboard"))] + #[expect( + clippy::collection_is_never_read, + reason = "binding owns the Windows clipboard so it stays alive for the connection's lifetime" + )] + let _win_clipboard; + + #[cfg(feature = "clipboard")] + let cliprdr_factory: Option>; + + #[cfg(feature = "clipboard")] + { + match self.config.channels.clipboard { + ClipboardType::Disable => { + cliprdr_factory = None; + #[cfg(windows)] + { + _win_clipboard = None; + } + } + ClipboardType::Stub => { + use ironrdp_cliprdr_native::StubClipboard; + let stub = StubClipboard::new(); + cliprdr_factory = Some(stub.backend_factory()); + #[cfg(windows)] + { + _win_clipboard = None; + } + } + ClipboardType::Enable => { + #[cfg(windows)] + { + use crate::clipboard::ClientClipboardMessageProxy; + use ironrdp_cliprdr_native::WinClipboard; + match WinClipboard::new(ClientClipboardMessageProxy::new(self.input_event_sender.clone())) { + Ok(win_cb) => { + cliprdr_factory = Some(win_cb.backend_factory()); + _win_clipboard = Some(win_cb); + } + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(ironrdp_connector::custom_err!( + "Windows clipboard initialization", + e + ))) + .await; + return; + } + } + } -pub struct RdpClient { - pub config: Config, - pub output_event_sender: mpsc::Sender, - pub input_event_receiver: mpsc::UnboundedReceiver, - pub cliprdr_factory: Option>, - pub dvc_pipe_proxy_factory: DvcPipeProxyFactory, -} + #[cfg(not(windows))] + { + use ironrdp_cliprdr_native::StubClipboard; + let stub = StubClipboard::new(); + cliprdr_factory = Some(stub.backend_factory()); + } + } + } + } -impl RdpClient { - pub async fn run(mut self) { + // Resolve the per-connection cliprdr factory reference once. `Option<&dyn …>` is `Copy`, + // so it can be threaded into every connect attempt across reconnects. + #[cfg(feature = "clipboard")] + let cliprdr_factory: CliprdrFactoryRef<'_> = cliprdr_factory.as_deref(); + #[cfg(not(feature = "clipboard"))] + let cliprdr_factory: CliprdrFactoryRef<'_> = core::marker::PhantomData; + + // ── Connection + session loop ───────────────────────────────────────── loop { - let (connection_result, framed) = if let Some(rdcleanpath) = self.config.rdcleanpath.as_ref() { - match connect_ws( - &self.config, - rdcleanpath, - self.cliprdr_factory.as_deref(), - &self.dvc_pipe_proxy_factory, - ) - .await - { - Ok(result) => result, - Err(e) => { - let _ = self - .output_event_sender - .send(RdpOutputEvent::ConnectionFailure(e)) - .await; - break; + let (connection_result, framed) = match &self.config.transport { + Transport::Direct => { + match connect_direct(&self.config, &self.input_event_sender, cliprdr_factory).await { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } } } - } else { - match connect( - &self.config, - self.cliprdr_factory.as_deref(), - &self.dvc_pipe_proxy_factory, - ) - .await - { - Ok(result) => result, - Err(e) => { - let _ = self - .output_event_sender - .send(RdpOutputEvent::ConnectionFailure(e)) - .await; - break; + + #[cfg(feature = "gateway")] + Transport::Gateway(gw) => { + match connect_gateway(&self.config, gw, &self.input_event_sender, cliprdr_factory).await { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } + } + } + + Transport::RDCleanPath(rdcp) => { + match connect_rdcleanpath_transport(&self.config, rdcp, &self.input_event_sender, cliprdr_factory) + .await + { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } } } }; @@ -180,68 +257,69 @@ impl RdpClient { } } -enum RdpControlFlow { - ReconnectWithNewSize { width: u16, height: u16 }, - TerminatedGracefully(GracefulDisconnectReason), -} - -trait AsyncReadWrite: AsyncRead + AsyncWrite {} - -impl AsyncReadWrite for T where T: AsyncRead + AsyncWrite {} - -type UpgradedFramed = ironrdp_tokio::TokioFramed>; - -async fn connect( +// ── Connector builder ───────────────────────────────────────────────────────── + +/// Reference to the cliprdr backend factory threaded into the connect helpers. +/// +/// Collapses to a zero-sized placeholder when the `clipboard` feature is disabled, so the +/// connect-helper signatures don't need `#[cfg]` on this parameter. +#[cfg(feature = "clipboard")] +type CliprdrFactoryRef<'a> = Option<&'a (dyn CliprdrBackendFactory + Send)>; +#[cfg(not(feature = "clipboard"))] +type CliprdrFactoryRef<'a> = core::marker::PhantomData<&'a ()>; + +/// Build a fully wired [`ironrdp_connector::ClientConnector`] with all feature-gated channels attached. +/// +/// This helper is used by all transport paths. The cliprdr backend is (re)built here, per +/// connection, from `cliprdr_factory`. +fn build_connector( config: &Config, - cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>, - dvc_pipe_proxy_factory: &DvcPipeProxyFactory, -) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { - let dest = config.destination.to_string(); - - let (client_addr, stream) = if let Some(ref gw_config) = config.gw { - let (gw, client_addr) = ironrdp_mstsgu::GwClient::connect(gw_config, &config.connector.client_name) - .await - .map_err(|e| connector::custom_err!("GW Connect", e))?; - (client_addr, tokio_util::either::Either::Left(gw)) - } else { - let stream = TcpStream::connect(dest) - .await - .map_err(|e| connector::custom_err!("TCP connect", e))?; - let client_addr = stream - .local_addr() - .map_err(|e| connector::custom_err!("get socket local address", e))?; - (client_addr, tokio_util::either::Either::Right(stream)) - }; - let mut framed = ironrdp_tokio::TokioFramed::new(stream); - - let mut drdynvc = ironrdp::dvc::DrdynvcClient::new() + client_addr: SocketAddr, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ironrdp_connector::ClientConnector { + // `input_sender` is only consumed by the optional DVC wirings below, and `cliprdr_factory` + // only by the optional CLIPRDR attachment; discard them explicitly when those are compiled out. + #[cfg(not(any(feature = "dvc-pipe-proxy", all(windows, feature = "dvc-com-plugin"))))] + let _ = input_sender; + #[cfg(not(feature = "clipboard"))] + let _ = cliprdr_factory; + + let mut drdynvc = ironrdp_dvc::DrdynvcClient::new() .with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))) .with_dynamic_channel(EchoClient::new()); - // Instantiate all DVC proxies - for proxy in config.dvc_pipe_proxies.iter() { + // Attach DVC pipe proxies. + #[cfg(feature = "dvc-pipe-proxy")] + for proxy in &config.dvc_pipe_proxies { let channel_name = proxy.channel_name.clone(); let pipe_name = proxy.pipe_name.clone(); - - trace!(%channel_name, %pipe_name, "Creating DVC proxy"); - - drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name)); + trace!(%channel_name, %pipe_name, "Creating DVC pipe proxy"); + let sender = input_sender.clone(); + drdynvc = drdynvc.with_dynamic_channel(DvcNamedPipeProxy::new( + &channel_name, + &pipe_name, + move |channel_id, messages| { + sender + .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) + .map_err(|_| pdu_other_err!("send DVC messages to the event loop"))?; + Ok(()) + }, + )); } - // Load DVC COM plugins (Windows only) - #[cfg(windows)] + // Load DVC COM plugins (Windows + dvc-com-plugin feature). + #[cfg(all(windows, feature = "dvc-com-plugin"))] { - let sender = dvc_pipe_proxy_factory.input_sender(); - for plugin_path in config.dvc_plugins.iter() { + for plugin_path in &config.dvc_plugins { info!(dll = %plugin_path.display(), "Loading DVC COM plugin"); - - let sender_clone = sender.clone(); + let sender_clone = input_sender.clone(); match load_dvc_plugin(plugin_path, move || { let sender = sender_clone.clone(); Box::new(move |channel_id, messages| { sender .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) - .map_err(|_error| pdu_other_err!("send COM DVC messages to the event loop"))?; + .map_err(|_| pdu_other_err!("send COM DVC messages to the event loop"))?; Ok(()) }) }) { @@ -258,158 +336,229 @@ async fn connect( } } - let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr) - .with_static_channel(drdynvc) - .with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new()))) - .with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0)); + // Attach user-defined DVC channels from the extension registry. + for attach_dvc in &config.extensions.dvc_channels { + attach_dvc(&mut drdynvc); + } - if let Some(builder) = cliprdr_factory { - let backend = builder.build_cliprdr_backend(); + // Clone the connector config so we can apply runtime overrides before handing it to the + // connector. We want to set `enable_audio_playback` consistently with `channels.sound`. + let mut connector_config = config.connector.clone(); - let cliprdr = cliprdr::Cliprdr::new(backend); + // If sound is disabled at runtime (or the feature is off) ensure the connector doesn't + // advertise audio support, which would confuse the server. + #[cfg(not(feature = "sound"))] + { + connector_config.enable_audio_playback = false; + } + #[cfg(feature = "sound")] + if !config.channels.sound { + connector_config.enable_audio_playback = false; + } - connector.attach_static_channel(cliprdr); + // Honor the runtime QOI/QOIZ codec toggles. Both codecs are compiled in and advertised by + // default, but can be disabled at runtime; when disabled we drop them from the advertised + // bitmap codec list so the server won't negotiate them. + #[cfg(any(feature = "qoi", feature = "qoiz"))] + if let Some(bitmap) = connector_config.bitmap.as_mut() { + use ironrdp_pdu::rdp::capability_sets::CodecProperty; + + bitmap.codecs.0.retain(|codec| match codec.property { + #[cfg(feature = "qoi")] + CodecProperty::Qoi => config.channels.qoi, + #[cfg(feature = "qoiz")] + CodecProperty::QoiZ => config.channels.qoiz, + _ => true, + }); } - let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await?; + let mut connector = + ironrdp_connector::ClientConnector::new(connector_config, client_addr).with_static_channel(drdynvc); - debug!("TLS upgrade"); + // Attach RDPSND (audio). + #[cfg(feature = "sound")] + if config.channels.sound { + connector = connector.with_static_channel(ironrdp_rdpsnd::client::Rdpsnd::new(Box::new( + cpal::RdpsndBackend::new(), + ))); + } - // Ensure there is no leftover - let (initial_stream, leftover_bytes) = framed.into_inner(); + // Attach RDPDR (device redirection). + #[cfg(feature = "rdpdr")] + if config.channels.rdpdr.enabled { + #[cfg_attr( + not(feature = "smartcard"), + expect( + unused_mut, + reason = "rdpdr_channel is only reassigned when the smartcard feature is enabled" + ) + )] + let mut rdpdr_channel = + ironrdp_rdpdr::Rdpdr::new(Box::new(ironrdp_rdpdr::NoopRdpdrBackend), "IronRDP".to_owned()); + #[cfg(feature = "smartcard")] + if config.channels.rdpdr.smartcard { + rdpdr_channel = rdpdr_channel.with_smartcard(0); + } + connector = connector.with_static_channel(rdpdr_channel); + } + + // Attach CLIPRDR (clipboard redirection). The backend is built fresh per connection. + #[cfg(feature = "clipboard")] + if let Some(factory) = cliprdr_factory { + let backend = factory.build_cliprdr_backend(); + connector.attach_static_channel(ironrdp_cliprdr::Cliprdr::new(backend)); + } + + // Attach user-defined static channels from the extension registry. + for attach_sc in &config.extensions.static_channels { + attach_sc(&mut connector); + } + + connector +} + +// ── Transport-specific connect helpers ──────────────────────────────────────── + +trait AsyncReadWrite: AsyncRead + AsyncWrite {} +impl AsyncReadWrite for T where T: AsyncRead + AsyncWrite {} +type UpgradedFramed = ironrdp_tokio::TokioFramed>; - let (upgraded_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, config.destination.name()) +/// Direct TCP → TLS connection (no gateway). +async fn connect_direct( + config: &Config, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { + let dest = config.destination.to_string(); + let stream = TcpStream::connect(&dest) .await - .map_err(|e| connector::custom_err!("TLS upgrade", e))?; + .map_err(|e| ironrdp_connector::custom_err!("TCP connect", e))?; + let client_addr = stream + .local_addr() + .map_err(|e| ironrdp_connector::custom_err!("get socket local address", e))?; + let framed = ironrdp_tokio::TokioFramed::new(stream); - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); + let connector = build_connector(config, client_addr, input_sender, cliprdr_factory); - let erased_stream: Box = Box::new(upgraded_stream); - let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + tls_handshake_and_finalize(framed, connector, config).await +} - let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert) - .ok_or_else(|| connector::general_err!("unable to extract tls server public key"))?; - let connection_result = ironrdp_tokio::connect_finalize( - upgraded, - connector, - &mut upgraded_framed, - &mut ReqwestNetworkClient::new(), - (&config.destination).into(), - server_public_key.to_owned(), - config.kerberos_config.clone(), - ) - .await?; +/// RDS gateway TCP → gateway auth → TLS connection. +#[cfg(feature = "gateway")] +async fn connect_gateway( + config: &Config, + gw: &crate::config::GatewayConfig, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { + use ironrdp_mstsgu::GwConnectTarget; + + // Build the GwConnectTarget. `server` is the RDP target derived from `config.destination`. + // TODO: preserve the destination port; ironrdp-mstsgu may currently hard-code 3389. + let gw_target = GwConnectTarget { + gw_endpoint: gw.endpoint.clone(), + gw_user: gw.username.clone(), + gw_pass: gw.password.clone(), + server: config.destination.name().to_owned(), + }; - debug!(?connection_result); + let (gw_stream, client_addr) = ironrdp_mstsgu::GwClient::connect(&gw_target, &config.connector.client_name) + .await + .map_err(|e| ironrdp_connector::custom_err!("GW connect", e))?; - Ok((connection_result, upgraded_framed)) + let framed = ironrdp_tokio::TokioFramed::new(gw_stream); + + let connector = build_connector(config, client_addr, input_sender, cliprdr_factory); + + tls_handshake_and_finalize(framed, connector, config).await } -async fn connect_ws( +/// RDCleanPath WebSocket → RDCleanPath handshake connection. +async fn connect_rdcleanpath_transport( config: &Config, - rdcleanpath: &RDCleanPathConfig, - cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>, - dvc_pipe_proxy_factory: &DvcPipeProxyFactory, + rdcp: &RDCleanPathConfig, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, ) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { - let hostname = rdcleanpath + let hostname = rdcp .url .host_str() - .ok_or_else(|| connector::general_err!("host missing from the URL"))?; - - let port = rdcleanpath.url.port_or_known_default().unwrap_or(443); + .ok_or_else(|| ironrdp_connector::general_err!("host missing from the URL"))?; + let port = rdcp.url.port_or_known_default().unwrap_or(443); let socket = TcpStream::connect((hostname, port)) .await - .map_err(|e| connector::custom_err!("TCP connect", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("TCP connect", e))?; socket .set_nodelay(true) - .map_err(|e| connector::custom_err!("set TCP_NODELAY", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("set TCP_NODELAY", e))?; let client_addr = socket .local_addr() - .map_err(|e| connector::custom_err!("get socket local address", e))?; + .map_err(|e| ironrdp_connector::custom_err!("get socket local address", e))?; - let (ws, _) = tokio_tungstenite::client_async_tls(rdcleanpath.url.as_str(), socket) + let (ws, _) = tokio_tungstenite::client_async_tls(rdcp.url.as_str(), socket) .await - .map_err(|e| connector::custom_err!("WS connect", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("WS connect", e))?; let ws = crate::ws::websocket_compat(ws); - let mut framed = ironrdp_tokio::TokioFramed::new(ws); - let mut drdynvc = ironrdp::dvc::DrdynvcClient::new() - .with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))) - .with_dynamic_channel(EchoClient::new()); + let mut connector = build_connector(config, client_addr, input_sender, cliprdr_factory); - // Instantiate all DVC proxies - for proxy in config.dvc_pipe_proxies.iter() { - let channel_name = proxy.channel_name.clone(); - let pipe_name = proxy.pipe_name.clone(); + let destination = config.destination.to_string(); + let (upgraded, server_public_key) = + rdcleanpath_handshake(&mut framed, &mut connector, destination, rdcp.auth_token.clone(), None).await?; - trace!(%channel_name, %pipe_name, "Creating DVC proxy"); + let connection_result = ironrdp_tokio::connect_finalize( + upgraded, + connector, + &mut framed, + &mut ReqwestNetworkClient::new(), + (&config.destination).into(), + server_public_key, + config.kerberos_config.clone(), + ) + .await?; - drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name)); - } + let (ws, leftover_bytes) = framed.into_inner(); + let erased_stream: Box = Box::new(ws); + let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); - // Load DVC COM plugins (Windows only) - #[cfg(windows)] - { - let sender = dvc_pipe_proxy_factory.input_sender(); - for plugin_path in config.dvc_plugins.iter() { - info!(dll = %plugin_path.display(), "Loading DVC COM plugin"); + Ok((connection_result, upgraded_framed)) +} - let sender_clone = sender.clone(); - match load_dvc_plugin(plugin_path, move || { - let sender = sender_clone.clone(); - Box::new(move |channel_id, messages| { - sender - .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) - .map_err(|_error| pdu_other_err!("send COM DVC messages to the event loop"))?; - Ok(()) - }) - }) { - Ok(channels) => { - for channel in channels { - info!(channel_name = %channel.channel_name(), "Registered COM DVC channel"); - drdynvc = drdynvc.with_dynamic_channel(channel); - } - } - Err(e) => { - error!(dll = %plugin_path.display(), error = %e, "Failed to load DVC COM plugin"); - } - } - } - } +// ── Shared TLS handshake ────────────────────────────────────────────────────── - let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr) - .with_static_channel(drdynvc) - .with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new()))) - .with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0)); +async fn tls_handshake_and_finalize( + mut framed: ironrdp_tokio::TokioFramed, + mut connector: ironrdp_connector::ClientConnector, + config: &Config, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> +where + S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, +{ + let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await?; - if let Some(builder) = cliprdr_factory { - let backend = builder.build_cliprdr_backend(); + debug!("TLS upgrade"); - let cliprdr = cliprdr::Cliprdr::new(backend); + let (initial_stream, leftover_bytes) = framed.into_inner(); - connector.attach_static_channel(cliprdr); - } + let (tls_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, config.destination.name()) + .await + .map_err(|e| ironrdp_connector::custom_err!("TLS upgrade", e))?; - let destination = config.destination.to_string(); + let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); - let (upgraded, server_public_key) = connect_rdcleanpath( - &mut framed, - &mut connector, - destination, - rdcleanpath.auth_token.clone(), - None, - ) - .await?; + let erased_stream: Box = Box::new(tls_stream); + let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + + let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert) + .ok_or_else(|| ironrdp_connector::general_err!("unable to extract tls server public key"))? + .to_owned(); let connection_result = ironrdp_tokio::connect_finalize( upgraded, connector, - &mut framed, + &mut upgraded_framed, &mut ReqwestNetworkClient::new(), (&config.destination).into(), server_public_key, @@ -417,16 +566,14 @@ async fn connect_ws( ) .await?; - let (ws, leftover_bytes) = framed.into_inner(); - let erased_stream: Box = Box::new(ws); - let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); - Ok((connection_result, upgraded_framed)) } -async fn connect_rdcleanpath( +// ── RDCleanPath handshake ───────────────────────────────────────────────────── + +async fn rdcleanpath_handshake( framed: &mut ironrdp_tokio::Framed, - connector: &mut connector::ClientConnector, + connector: &mut ironrdp_connector::ClientConnector, destination: String, proxy_auth_token: String, pcb: Option, @@ -434,40 +581,36 @@ async fn connect_rdcleanpath( where S: ironrdp_tokio::FramedRead + FramedWrite, { - use ironrdp::connector::Sequence as _; + use ironrdp_connector::Sequence as _; use x509_cert::der::Decode as _; #[derive(Clone, Copy, Debug)] struct RDCleanPathHint; - const RDCLEANPATH_HINT: RDCleanPathHint = RDCleanPathHint; - impl ironrdp::pdu::PduHint for RDCleanPathHint { - fn find_size(&self, bytes: &[u8]) -> ironrdp::core::DecodeResult> { + impl ironrdp_pdu::PduHint for RDCleanPathHint { + fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult> { match ironrdp_rdcleanpath::RDCleanPathPdu::detect(bytes) { ironrdp_rdcleanpath::DetectionResult::Detected { total_length, .. } => Ok(Some((true, total_length))), ironrdp_rdcleanpath::DetectionResult::NotEnoughBytes => Ok(None), - ironrdp_rdcleanpath::DetectionResult::Failed => Err(ironrdp::core::other_err!( - "RDCleanPathHint", - "detection failed (invalid PDU)" - )), + ironrdp_rdcleanpath::DetectionResult::Failed => { + Err(ironrdp_core::other_err!("RDCleanPathHint", "detection failed")) + } } } } let mut buf = WriteBuf::new(); + info!("Begin RDCleanPath connection procedure"); - info!("Begin connection procedure"); - + // Send X224 + RDCleanPath request. { - // RDCleanPath request - - let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else { - return Err(connector::general_err!("invalid connector state (send request)")); + let ironrdp_connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else { + return Err(ironrdp_connector::general_err!( + "invalid connector state (send request)" + )); }; - debug_assert!(connector.next_pdu_hint().is_none()); - let written = connector.step_no_input(&mut buf)?; let x224_pdu_len = written.size().expect("written size"); debug_assert_eq!(x224_pdu_len, buf.filled_len()); @@ -475,38 +618,34 @@ where let rdcleanpath_req = ironrdp_rdcleanpath::RDCleanPathPdu::new_request(x224_pdu, destination, proxy_auth_token, pcb) - .map_err(|e| connector::custom_err!("new RDCleanPath request", e))?; + .map_err(|e| ironrdp_connector::custom_err!("new RDCleanPath request", e))?; debug!(message = ?rdcleanpath_req, "Send RDCleanPath request"); let rdcleanpath_req = rdcleanpath_req .to_der() - .map_err(|e| connector::custom_err!("RDCleanPath request encode", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("RDCleanPath request encode", e))?; framed .write_all(&rdcleanpath_req) .await - .map_err(|e| connector::custom_err!("couldn't write RDCleanPath request", e))?; + .map_err(|e| ironrdp_connector::custom_err!("couldn't write RDCleanPath request", e))?; } + // Read RDCleanPath response. { - // RDCleanPath response - let rdcleanpath_res = framed .read_by_hint(&RDCLEANPATH_HINT) .await - .map_err(|e| connector::custom_err!("read RDCleanPath request", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("read RDCleanPath response", e))?; let rdcleanpath_res = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_res) - .map_err(|e| connector::custom_err!("RDCleanPath response decode", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("RDCleanPath response decode", e))?; debug!(message = ?rdcleanpath_res, "Received RDCleanPath PDU"); let (x224_connection_response, server_cert_chain) = match rdcleanpath_res .into_enum() - .map_err(|e| connector::custom_err!("invalid RDCleanPath PDU", e))? + .map_err(|e| ironrdp_connector::custom_err!("invalid RDCleanPath PDU", e))? { ironrdp_rdcleanpath::RDCleanPath::Request { .. } => { - return Err(connector::general_err!( - "received an unexpected RDCleanPath type (request)", + return Err(ironrdp_connector::general_err!( + "received unexpected RDCleanPath type (request)" )); } ironrdp_rdcleanpath::RDCleanPath::Response { @@ -515,68 +654,70 @@ where server_addr: _, } => (x224_connection_response, server_cert_chain), ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => { - return Err(connector::custom_err!("received an RDCleanPath error", error)); + return Err(ironrdp_connector::custom_err!("received RDCleanPath error", error)); } ironrdp_rdcleanpath::RDCleanPath::NegotiationErr { x224_connection_response, } => { - // Try to decode as X.224 Connection Confirm to extract negotiation failure details. if let Ok(x224_confirm) = ironrdp_core::decode::< - ironrdp::pdu::x224::X224, + ironrdp_pdu::x224::X224, >(&x224_connection_response) { - if let ironrdp::pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { - // Convert to negotiation failure instead of generic RDCleanPath error. - let negotiation_failure = connector::NegotiationFailure::from(code); - return Err(connector::ConnectorError::new( + if let ironrdp_pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { + let negotiation_failure = ironrdp_connector::NegotiationFailure::from(code); + return Err(ironrdp_connector::ConnectorError::new( "RDP negotiation failed", - connector::ConnectorErrorKind::Negotiation(negotiation_failure), + ironrdp_connector::ConnectorErrorKind::Negotiation(negotiation_failure), )); } } - - // Fallback to generic error if we can't decode the negotiation failure. - return Err(connector::general_err!("received an RDCleanPath negotiation error")); + return Err(ironrdp_connector::general_err!( + "received RDCleanPath negotiation error" + )); } }; - let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { - return Err(connector::general_err!("invalid connector state (wait confirm)")); + let ironrdp_connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { + return Err(ironrdp_connector::general_err!( + "invalid connector state (wait confirm)" + )); }; - debug_assert!(connector.next_pdu_hint().is_some()); buf.clear(); let written = connector.step(x224_connection_response.as_bytes(), &mut buf)?; - debug_assert!(written.is_nothing()); let server_cert = server_cert_chain .into_iter() .next() - .ok_or_else(|| connector::general_err!("server cert chain missing from rdcleanpath response"))?; + .ok_or_else(|| ironrdp_connector::general_err!("server cert chain missing from rdcleanpath response"))?; let cert = x509_cert::Certificate::from_der(server_cert.as_bytes()) - .map_err(|e| connector::custom_err!("server cert chain missing from rdcleanpath response", e))?; + .map_err(|e| ironrdp_connector::custom_err!("server cert decode", e))?; let server_public_key = cert .tbs_certificate .subject_public_key_info .subject_public_key .as_bytes() - .ok_or_else(|| connector::general_err!("subject public key BIT STRING is not aligned"))? + .ok_or_else(|| ironrdp_connector::general_err!("subject public key BIT STRING is not aligned"))? .to_owned(); let should_upgrade = ironrdp_tokio::skip_connect_begin(connector); - - // At this point, proxy established the TLS session. - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector); Ok((upgraded, server_public_key)) } } +// ── Active session ──────────────────────────────────────────────────────────── + +enum RdpControlFlow { + ReconnectWithNewSize { width: u16, height: u16 }, + TerminatedGracefully(GracefulDisconnectReason), +} + async fn active_session( framed: UpgradedFramed, connection_result: ConnectionResult, @@ -589,22 +730,20 @@ async fn active_session( connection_result.desktop_size.width, connection_result.desktop_size.height, ); - let mut active_stage = ActiveStage::new(connection_result); - // Timer interval for driving clipboard lock timeouts (5 second interval) + // Timer interval for driving clipboard lock timeouts. 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() => { - let (action, payload) = frame.map_err(|e| session::custom_err!("read frame", e))?; + let (action, payload) = frame.map_err(|e| ironrdp_session::custom_err!("read frame", e))?; trace!(?action, frame_length = payload.len(), "Frame received"); - active_stage.process(&mut image, action, &payload)? } input_event = input_event_receiver.recv() => { - let input_event = input_event.ok_or_else(|| session::general_err!("GUI is stopped"))?; + let input_event = input_event.ok_or_else(|| ironrdp_session::general_err!("GUI is stopped"))?; match input_event { RdpInputEvent::Resize { width, height, scale_factor, physical_size } => { @@ -625,7 +764,7 @@ async fn active_session( let height = u16::try_from(height).expect("always in the range"); return Ok(RdpControlFlow::ReconnectWithNewSize { width, height }) } - }, + } RdpInputEvent::FastPath(events) => { trace!(?events); active_stage.process_fastpath_input(&mut image, &events)? @@ -633,28 +772,29 @@ async fn active_session( RdpInputEvent::Close => { active_stage.graceful_shutdown()? } + #[cfg(feature = "clipboard")] RdpInputEvent::Clipboard(event) => { - if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { + if let Some(cliprdr_client) = active_stage.get_svc_processor_mut::() { if let Some(svc_messages) = match event { ClipboardMessage::SendInitiateCopy(formats) => { - Some(cliprdr.initiate_copy(&formats) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.initiate_copy(&formats) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendFormatData(response) => { - Some(cliprdr.submit_format_data(response) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.submit_format_data(response) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendInitiatePaste(format) => { - Some(cliprdr.initiate_paste(format) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.initiate_paste(format) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendFileContentsRequest(request) => { - Some(cliprdr.request_file_contents(request) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.request_file_contents(request) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendFileContentsResponse(response) => { - Some(cliprdr.submit_file_contents(response) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.submit_file_contents(response) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::Error(e) => { error!("Clipboard backend error: {}", e); @@ -662,29 +802,27 @@ async fn active_session( } } { let frame = active_stage.process_svc_processor_messages(svc_messages)?; - // Send the messages to the server vec![ActiveStageOutput::ResponseFrame(frame)] } else { - // No messages to send to the server Vec::new() } - } else { + } else { warn!("Clipboard event received, but Cliprdr is not available"); Vec::new() } } RdpInputEvent::SendDvcMessages { channel_id, messages } => { trace!(channel_id, ?messages, "Send DVC messages"); - let frame = active_stage.encode_dvc_messages(messages)?; vec![ActiveStageOutput::ResponseFrame(frame)] } } } _ = cleanup_interval.tick() => { - // Drive clipboard lock timeout cleanup - if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { - match cliprdr.drive_timeouts() { + // Drive clipboard lock timeout cleanup. + #[cfg(feature = "clipboard")] + if let Some(cliprdr_client) = active_stage.get_svc_processor_mut::() { + match cliprdr_client.drive_timeouts() { Ok(svc_messages) => { let frame = active_stage.process_svc_processor_messages(svc_messages)?; if !frame.is_empty() { @@ -701,6 +839,8 @@ async fn active_session( } else { Vec::new() } + #[cfg(not(feature = "clipboard"))] + Vec::new() } }; @@ -709,7 +849,7 @@ async fn active_session( ActiveStageOutput::ResponseFrame(frame) => writer .write_all(&frame) .await - .map_err(|e| session::custom_err!("write response", e))?, + .map_err(|e| ironrdp_session::custom_err!("write response", e))?, ActiveStageOutput::GraphicsUpdate(_region) => { let buffer: Vec = image .data() @@ -721,58 +861,57 @@ async fn active_session( u32::from_be_bytes([0, r, g, b]) }) .collect(); - output_event_sender .send(RdpOutputEvent::Image { buffer, width: NonZeroU16::new(image.width()) - .ok_or_else(|| session::general_err!("width is zero"))?, + .ok_or_else(|| ironrdp_session::general_err!("width is zero"))?, height: NonZeroU16::new(image.height()) - .ok_or_else(|| session::general_err!("height is zero"))?, + .ok_or_else(|| ironrdp_session::general_err!("height is zero"))?, }) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerDefault => { output_event_sender .send(RdpOutputEvent::PointerDefault) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerHidden => { output_event_sender .send(RdpOutputEvent::PointerHidden) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerPosition { x, y } => { output_event_sender .send(RdpOutputEvent::PointerPosition { x, y }) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerBitmap(pointer) => { output_event_sender .send(RdpOutputEvent::PointerBitmap(pointer)) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::DeactivateAll(mut connection_activation) => { - // Execute the Deactivation-Reactivation Sequence: + // Deactivation-Reactivation Sequence: // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 - debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); + debug!("Executing Deactivation-Reactivation Sequence"); let mut buf = WriteBuf::new(); 'activation_seq: loop { let written = single_sequence_step_read(&mut reader, &mut *connection_activation, &mut buf) .await - .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e))?; - + .map_err(|e| { + ironrdp_session::custom_err!("read deactivation-reactivation sequence step", e) + })?; if written.size().is_some() { writer.write_all(buf.filled()).await.map_err(|e| { - session::custom_err!("write deactivation-reactivation sequence step", e) + ironrdp_session::custom_err!("write deactivation-reactivation sequence step", e) })?; } - if let ConnectionActivationState::Finalized { io_channel_id, user_channel_id, @@ -783,9 +922,7 @@ async fn active_session( } = connection_activation.connection_activation_state() { debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); - // Update image size with the new desktop size. image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); - // Update the active stage with the new channel IDs and pointer settings. active_stage.set_fastpath_processor( fast_path::ProcessorBuilder { io_channel_id, diff --git a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs index e60d12adef..9e262727ec 100644 --- a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs +++ b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; -use ironrdp_client::config::ClipboardType; +use ironrdp_client::config::{ClipboardType, Transport}; use ironrdp_viewer::config::parse_config_from; use uuid::Uuid; @@ -48,7 +48,7 @@ fn gateway_is_disabled_when_gateway_usage_method_is_zero() { &[], ); - assert!(config.gw.is_none()); + assert!(!matches!(config.transport, Transport::Gateway(_))); } #[test] @@ -58,7 +58,7 @@ fn gateway_is_disabled_when_gateway_usage_method_is_four() { &[], ); - assert!(config.gw.is_none()); + assert!(!matches!(config.transport, Transport::Gateway(_))); } #[test] @@ -68,10 +68,12 @@ fn gateway_is_enabled_with_usage_method_one_and_file_credentials() { &[], ); - let gw = config.gw.expect("gateway should be configured"); - assert_eq!(gw.gw_endpoint, "gw.example.com:443"); - assert_eq!(gw.gw_user, "gw-user"); - assert_eq!(gw.gw_pass, "gw-pass"); + let Transport::Gateway(gw) = config.transport else { + panic!("gateway should be configured"); + }; + assert_eq!(gw.endpoint, "gw.example.com:443"); + assert_eq!(gw.username, "gw-user"); + assert_eq!(gw.password, "gw-pass"); } #[test] @@ -103,7 +105,7 @@ fn redirectclipboard_zero_disables_clipboard_for_default_mode() { &[], ); - assert!(matches!(config.clipboard_type, ClipboardType::Disable)); + assert!(matches!(config.channels.clipboard, ClipboardType::Disable)); } #[test] diff --git a/crates/ironrdp-tls/src/native_tls.rs b/crates/ironrdp-tls/src/native_tls.rs index 578178b33f..2b804e7fa9 100644 --- a/crates/ironrdp-tls/src/native_tls.rs +++ b/crates/ironrdp-tls/src/native_tls.rs @@ -14,12 +14,9 @@ where .use_sni(false) .build() .map(tokio_native_tls::TlsConnector::from) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(io::Error::other)?; - connector - .connect(server_name, stream) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? + connector.connect(server_name, stream).await.map_err(io::Error::other)? }; tls_stream.flush().await?; @@ -30,9 +27,9 @@ where let cert = tls_stream .get_ref() .peer_certificate() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "peer certificate is missing"))?; - let cert = cert.to_der().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(io::Error::other)? + .ok_or_else(|| io::Error::other("peer certificate is missing"))?; + let cert = cert.to_der().map_err(io::Error::other)?; x509_cert::Certificate::from_der(&cert).map_err(io::Error::other)? }; diff --git a/crates/ironrdp-viewer/Cargo.toml b/crates/ironrdp-viewer/Cargo.toml index 20ffbc2583..18ac91b171 100644 --- a/crates/ironrdp-viewer/Cargo.toml +++ b/crates/ironrdp-viewer/Cargo.toml @@ -25,17 +25,14 @@ test = false [features] default = ["rustls"] -rustls = ["ironrdp-client/rustls"] -native-tls = ["ironrdp-client/native-tls"] -qoi = ["ironrdp-client/qoi"] -qoiz = ["ironrdp-client/qoiz"] +rustls = ["ironrdp/rustls"] +native-tls = ["ironrdp/native-tls"] +qoi = ["ironrdp/qoi"] +qoiz = ["ironrdp/qoiz"] [dependencies] -ironrdp = { path = "../ironrdp", version = "0.16", features = ["input", "pdu"] } -ironrdp-client = { path = "../ironrdp-client", version = "0.1", default-features = false } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6" } +ironrdp = { path = "../ironrdp", features = ["connector", "cliprdr", "input", "pdu", "client", "client-all"] } ironrdp-cfg = { path = "../ironrdp-cfg" } -ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } ironrdp-propertyset = { path = "../ironrdp-propertyset" } ironrdp-rdpfile = { path = "../ironrdp-rdpfile" } diff --git a/crates/ironrdp-viewer/src/app.rs b/crates/ironrdp-viewer/src/app.rs index 952e79d097..a19703a011 100644 --- a/crates/ironrdp-viewer/src/app.rs +++ b/crates/ironrdp-viewer/src/app.rs @@ -6,10 +6,10 @@ use std::sync::Arc; use std::time::Instant; use anyhow::Context as _; +use ironrdp::client::rdp::{RdpInputEvent, RdpOutputEvent}; use ironrdp::pdu::input::MousePdu; use ironrdp::pdu::input::fast_path::FastPathInputEvent; use ironrdp::pdu::input::mouse::PointerFlags; -use ironrdp_client::rdp::{RdpInputEvent, RdpOutputEvent}; use raw_window_handle::{DisplayHandle, HasDisplayHandle as _}; use smallvec::SmallVec; use tokio::sync::mpsc; diff --git a/crates/ironrdp-viewer/src/clipboard.rs b/crates/ironrdp-viewer/src/clipboard.rs index 9b2855844a..cc304c0bc2 100644 --- a/crates/ironrdp-viewer/src/clipboard.rs +++ b/crates/ironrdp-viewer/src/clipboard.rs @@ -1,5 +1,5 @@ use ironrdp::cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; -use ironrdp_client::rdp::RdpInputEvent; +use ironrdp::client::rdp::RdpInputEvent; use tokio::sync::mpsc; use tracing::error; diff --git a/crates/ironrdp-viewer/src/config.rs b/crates/ironrdp-viewer/src/config.rs index 009f3b35ec..73db599677 100644 --- a/crates/ironrdp-viewer/src/config.rs +++ b/crates/ironrdp-viewer/src/config.rs @@ -7,13 +7,13 @@ use std::path::PathBuf; use anyhow::Context as _; use clap::Parser; use clap::clap_derive::ValueEnum; +use ironrdp::client::config::{ + ClipboardType as ResolvedClipboardType, Config, ConfigBuilder, Destination, DvcProxyInfo, GatewayConfig, + RDCleanPathConfig, Transport, +}; use ironrdp::connector::{self, Credentials}; use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; -use ironrdp_client::config::{ - ClipboardType as ResolvedClipboardType, Config, Destination, DvcProxyInfo, RDCleanPathConfig, -}; -use ironrdp_mstsgu::GwConnectTarget; use tap::prelude::*; use url::Url; @@ -239,7 +239,7 @@ struct Args { /// Automatically logon to the server by passing the INFO_AUTOLOGON flag /// /// This flag is ignored if CredSSP authentication is used. - /// You can use `--no-credssp` to ensure it’s not. + /// You can use `--no-credssp` to ensure it's not. #[clap(long)] autologon: bool, @@ -252,7 +252,7 @@ struct Args { /// Disable TLS + Network Level Authentication (NLA) using CredSSP /// /// NLA is used to authenticates RDP clients and servers before sending credentials over the network. - /// It’s not recommended to disable this. + /// It's not recommended to disable this. #[clap(long, alias = "no-nla")] no_credssp: bool, @@ -420,25 +420,24 @@ impl PartialConfig { }) .map_or(has_gateway_host, ironrdp_cfg::GatewayUsageMethod::is_gateway_required); - let mut gw: Option = + let mut gw_config: Option = use_gateway .then(|| properties.gateway_hostname()) .flatten() - .map(|gw_addr| GwConnectTarget { - gw_endpoint: gw_addr.to_owned(), - gw_user: String::new(), - gw_pass: String::new(), - server: String::new(), // TODO: non-standard port? also dont use here? + .map(|gw_addr| GatewayConfig { + endpoint: gw_addr.to_owned(), + username: String::new(), + password: String::new(), }); - if let Some(ref mut gw) = gw { + if let Some(ref mut gw) = gw_config { if let Ok(Some(gateway_credentials_source)) = properties.gateway_credentials_source() { // All known credential sources fall through to username/password prompts. // The value is available for future differentiation if needed. let _ = gateway_credentials_source; } - gw.gw_user = if let Some(gw_user) = properties.gateway_username() { + gw.username = if let Some(gw_user) = properties.gateway_username() { gw_user.to_owned() } else { inquire::Text::new("Gateway username:") @@ -446,7 +445,7 @@ impl PartialConfig { .context("Username prompt")? }; - gw.gw_pass = if let Some(gw_pass) = properties.gateway_password() { + gw.password = if let Some(gw_pass) = properties.gateway_password() { gw_pass.to_owned() } else { inquire::Password::new("Gateway password:") @@ -484,10 +483,6 @@ impl PartialConfig { .pipe(Destination::new)? }; - if let Some(ref mut gw) = gw { - gw.server = destination.name().to_owned(); // TODO - } - let username = if let Some(username) = properties.username() { username.to_owned() } else { @@ -640,31 +635,41 @@ impl PartialConfig { work_dir: properties.shell_working_directory().unwrap_or_default().to_owned(), }; - Ok(Config { - log_file: self.log_file, - gw, - kerberos_config, - destination, - connector, - clipboard_type, - rdcleanpath: self.rdcleanpath, - fake_events_interval, - dvc_pipe_proxies: self.dvc_pipe_proxies, - #[cfg(windows)] - dvc_plugins: self.dvc_plugins, - }) - } -} + // Determine the transport. RDCleanPath takes precedence over gateway. + let transport = if let Some(rdcp) = self.rdcleanpath { + Transport::RDCleanPath(rdcp) + } else if let Some(gw) = gw_config { + Transport::Gateway(gw) + } else { + Transport::Direct + }; -fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> ResolvedClipboardType { - if !redirect_clipboard { - return ResolvedClipboardType::Disable; - } + let mut builder = ConfigBuilder::new(connector, destination) + .with_transport(transport) + .with_clipboard(clipboard_type); - match cli { - ClipboardType::Enable => ResolvedClipboardType::Enable, - ClipboardType::Disable => ResolvedClipboardType::Disable, - ClipboardType::Stub => ResolvedClipboardType::Stub, + if let Some(kerberos_config) = kerberos_config { + builder = builder.with_kerberos_config(kerberos_config); + } + + if let Some(log_file) = self.log_file { + builder = builder.with_log_file(log_file); + } + + if let Some(fake_events_interval) = fake_events_interval { + builder = builder.with_fake_events_interval(fake_events_interval); + } + + for proxy in self.dvc_pipe_proxies { + builder = builder.with_dvc_pipe_proxy(proxy); + } + + #[cfg(windows)] + for plugin in self.dvc_plugins { + builder = builder.with_dvc_plugin(plugin); + } + + Ok(builder.build()) } } @@ -680,6 +685,18 @@ where PartialConfig::parse_from(args)?.into_config() } +fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> ResolvedClipboardType { + if !redirect_clipboard { + return ResolvedClipboardType::Disable; + } + + match cli { + ClipboardType::Enable => ResolvedClipboardType::Enable, + ClipboardType::Disable => ResolvedClipboardType::Disable, + ClipboardType::Stub => ResolvedClipboardType::Stub, + } +} + fn normalize_kdc_proxy_url_from_name(name: &str) -> String { if name.starts_with("http://") || name.starts_with("https://") { name.to_owned() diff --git a/crates/ironrdp-viewer/src/lib.rs b/crates/ironrdp-viewer/src/lib.rs index 69402deb08..9d1f6d63fb 100644 --- a/crates/ironrdp-viewer/src/lib.rs +++ b/crates/ironrdp-viewer/src/lib.rs @@ -10,5 +10,4 @@ #![allow(clippy::cast_sign_loss)] pub mod app; -pub mod clipboard; pub mod config; diff --git a/crates/ironrdp-viewer/src/main.rs b/crates/ironrdp-viewer/src/main.rs index 157ddb41f2..f62400be25 100644 --- a/crates/ironrdp-viewer/src/main.rs +++ b/crates/ironrdp-viewer/src/main.rs @@ -1,8 +1,7 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary use anyhow::Context as _; -use ironrdp_client::config::ClipboardType; -use ironrdp_client::rdp::{DvcPipeProxyFactory, RdpClient, RdpInputEvent, RdpOutputEvent}; +use ironrdp::client::rdp::{RdpClient, RdpOutputEvent}; use ironrdp_viewer::app::App; use ironrdp_viewer::config::PartialConfig; use tokio::runtime; @@ -27,16 +26,20 @@ fn main() -> anyhow::Result<()> { debug!("Initialize App"); let event_loop = EventLoop::::with_user_event().build()?; let event_loop_proxy = event_loop.create_proxy(); - let (input_event_sender, input_event_receiver) = RdpInputEvent::create_channel(); let (output_event_sender, mut output_event_receiver) = mpsc::channel::(64); let initial_window_size = PhysicalSize::new( u32::from(config.connector.desktop_size.width), u32::from(config.connector.desktop_size.height), ); + let fake_events_interval = config.fake_events_interval; + + let client = RdpClient::new(config, output_event_sender); + let input_event_sender = client.input_sender(); + let mut app = App::new( &event_loop, &input_event_sender, - config.fake_events_interval, + fake_events_interval, initial_window_size, ) .context("unable to initialize App")?; @@ -46,54 +49,6 @@ fn main() -> anyhow::Result<()> { .build() .context("unable to create tokio runtime")?; - // NOTE: we need to keep `win_clipboard` alive, otherwise it will be dropped before IronRDP - // starts and clipboard functionality will not be available. - #[cfg(windows)] - let _win_clipboard; - - let cliprdr_factory = match config.clipboard_type { - ClipboardType::Stub => { - use ironrdp_cliprdr_native::StubClipboard; - - let cliprdr = StubClipboard::new(); - let factory = cliprdr.backend_factory(); - Some(factory) - } - ClipboardType::Enable => { - #[cfg(windows)] - { - use ironrdp_cliprdr_native::WinClipboard; - use ironrdp_viewer::clipboard::ClientClipboardMessageProxy; - - let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?; - - let factory = cliprdr.backend_factory(); - _win_clipboard = cliprdr; - Some(factory) - } - #[cfg(not(windows))] - { - // No native clipboard backend available on this platform; fall back to stub. - use ironrdp_cliprdr_native::StubClipboard; - - let cliprdr = StubClipboard::new(); - let factory = cliprdr.backend_factory(); - Some(factory) - } - } - ClipboardType::Disable => None, - }; - - let dvc_pipe_proxy_factory = DvcPipeProxyFactory::new(input_event_sender); - - let client = RdpClient { - config, - output_event_sender, - input_event_receiver, - cliprdr_factory, - dvc_pipe_proxy_factory, - }; - // Forward output events from the library's mpsc channel to winit's `EventLoopProxy`. // // The library is winit-agnostic: it just emits `RdpOutputEvent`s on a plain @@ -114,6 +69,7 @@ fn main() -> anyhow::Result<()> { debug!("Run App"); event_loop.run_app(&mut app)?; + Ok(()) } diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index d557efec77..856f7827a5 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -33,8 +33,23 @@ rdpdr = ["dep:ironrdp-rdpdr"] rdpsnd = ["dep:ironrdp-rdpsnd"] displaycontrol = ["dep:ironrdp-displaycontrol"] echo = ["dep:ironrdp-echo"] -qoi = ["ironrdp-server?/qoi", "ironrdp-pdu?/qoi", "ironrdp-connector?/qoi", "ironrdp-session?/qoi"] -qoiz = ["ironrdp-server?/qoiz", "ironrdp-pdu?/qoiz", "ironrdp-connector?/qoiz", "ironrdp-session?/qoiz"] +mstsgu = ["dep:ironrdp-mstsgu"] +client = ["dep:ironrdp-client"] +# TLS backends for the client (exactly one is required when the client is enabled). +rustls = ["ironrdp-client?/rustls", "ironrdp-mstsgu?/rustls"] +native-tls = ["ironrdp-client?/native-tls", "ironrdp-mstsgu?/native-tls"] +# Optional client subsystems, forwarded so consumers can opt in without naming `ironrdp-client`. +client-sound = ["ironrdp-client?/sound"] +client-clipboard = ["ironrdp-client?/clipboard"] +client-rdpdr = ["ironrdp-client?/rdpdr"] +client-smartcard = ["ironrdp-client?/smartcard"] +client-gateway = ["ironrdp-client?/gateway"] +client-dvc-pipe-proxy = ["ironrdp-client?/dvc-pipe-proxy"] +client-dvc-com-plugin = ["ironrdp-client?/dvc-com-plugin"] +client-all = ["ironrdp-client?/all"] +qoi = ["ironrdp-server?/qoi", "ironrdp-pdu?/qoi", "ironrdp-connector?/qoi", "ironrdp-session?/qoi", "ironrdp-client?/qoi"] +qoiz = ["ironrdp-server?/qoiz", "ironrdp-pdu?/qoiz", "ironrdp-connector?/qoiz", "ironrdp-session?/qoiz", "ironrdp-client?/qoiz"] + # Internal (PRIVATE!) features used to aid testing. # Don't rely on these whatsoever. They may disappear at any time. __bench = ["ironrdp-server/__bench"] @@ -55,6 +70,8 @@ ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6", optional = true } ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8", optional = true } # public ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7", optional = true } # public ironrdp-echo = { path = "../ironrdp-echo", version = "0.3", optional = true } # public +ironrdp-mstsgu = { path = "../ironrdp-mstsgu", version = "0.0.1", optional = true } # public +ironrdp-client = { path = "../ironrdp-client", version = "0.1", optional = true } # public [dev-dependencies] ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.9" } diff --git a/crates/ironrdp/src/lib.rs b/crates/ironrdp/src/lib.rs index 3ef760f6a7..1ca6ae70e7 100644 --- a/crates/ironrdp/src/lib.rs +++ b/crates/ironrdp/src/lib.rs @@ -16,6 +16,10 @@ pub use ironrdp_acceptor as acceptor; #[doc(inline)] pub use ironrdp_cliprdr as cliprdr; +#[cfg(feature = "client")] +#[doc(inline)] +pub use ironrdp_client as client; + #[cfg(feature = "connector")] #[doc(inline)] pub use ironrdp_connector as connector; @@ -44,6 +48,10 @@ pub use ironrdp_graphics as graphics; #[doc(inline)] pub use ironrdp_input as input; +#[cfg(feature = "mstsgu")] +#[doc(inline)] +pub use ironrdp_mstsgu as mstsgu; + #[cfg(feature = "pdu")] #[doc(inline)] pub use ironrdp_pdu as pdu; diff --git a/xtask/src/features.rs b/xtask/src/features.rs index b90916392e..6f1e051d81 100644 --- a/xtask/src/features.rs +++ b/xtask/src/features.rs @@ -151,17 +151,34 @@ const CASES: &[FeatureCheckCase] = &[ extra_args: &[], }, }, - // `ironrdp-tls`, `ironrdp-client`, `ironrdp-mstsgu` are intentionally - // outside this initial case set. The `exactly-one-of` TLS-backend - // constraint on `ironrdp-tls` needs `--mutually-exclusive-features`, - // `--at-least-one-of`, and `--exclude-no-default-features` on the - // cargo-hack invocation (cargo-hack does not honor - // `package.metadata.cargo-hack`), and the powerset surfaces a latent - // bug in `extract_tls_server_public_key` that uses `x509_cert::*` - // unconditionally instead of gating on `rustls | native-tls`. Both - // are tractable but out of scope for this gate's initial landing. - // The regular `Checks` job already exercises all three crates with - // their default features. + // `ironrdp-client` has a pair of mutually-exclusive, at-least-one-of TLS + // backends (`rustls` / `native-tls`). cargo-hack does not honor + // `package.metadata.cargo-hack`, so the constraint is expressed inline via + // `--mutually-exclusive-features`, `--at-least-one-of`, and + // `--exclude-no-default-features` (building with no TLS backend cannot + // compile, since `ironrdp-tls` requires one). + FeatureCheckCase { + name: "workspace/powerset-client", + invocation: Invocation::CargoHack { + packages: &["ironrdp-client"], + depth: 2, + extra_args: &[ + "--mutually-exclusive-features", + "rustls,native-tls", + "--at-least-one-of", + "rustls,native-tls", + "--exclude-no-default-features", + ], + }, + }, + // FIXME: `ironrdp-tls` and `ironrdp-mstsgu` are intentionally outside this initial + // case set. The `exactly-one-of` TLS-backend constraint on `ironrdp-tls` + // also needs the inline cargo-hack flags shown above, and the powerset + // surfaces a latent bug in `extract_tls_server_public_key` that uses + // `x509_cert::*` unconditionally instead of gating on `rustls | native-tls`. + // Both are tractable but out of scope for this gate's initial landing. + // The regular `Checks` job already exercises both crates with their default + // features. ]; /// Run every case sequentially. Mirrors what a contributor gets locally with From 8a8f8c84ea27e1140491cb5b33128b52c11202f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:53:10 +0900 Subject: [PATCH 295/325] refactor(client): drive RdpClient configuration from the PropertySet (#1389) Make ironrdp-client configurable almost entirely from a .rdp PropertySet so that a single ConfigBuilder::from_property_set overlay can build a working client, enabling fully reproducible sessions from .rdp files. Viewer-only settings remain in PartialConfig with special treatment. - cfg: add ironrdp_ vendor properties for compressionlevel, colordepth, dvcplugin, tls, serverpointer and autologon. - client: read compression level, color depth, DVC plugins and kerberos from the PropertySet; default bulk compression to K64 unless disabled or specified otherwise. - client: move anti-idle "fake events" into active_session; consumed via fake_events_interval, dropping it from the viewer GUI - viewer: upsert CLI flags into the PropertySet; prune PartialConfig to CLI-only fields --- Cargo.lock | 2 + crates/ironrdp-cfg/src/lib.rs | 199 +++++ crates/ironrdp-client/Cargo.toml | 2 + crates/ironrdp-client/src/config.rs | 827 ++++++++++++++++-- crates/ironrdp-client/src/rdp.rs | 54 +- .../rdp/capability_sets/bitmap_codecs/mod.rs | 5 + crates/ironrdp-propertyset/src/lib.rs | 7 + crates/ironrdp-session/src/x224/mod.rs | 3 + .../tests/config_rdp.rs | 35 +- crates/ironrdp-viewer/src/app.rs | 39 +- crates/ironrdp-viewer/src/config.rs | 448 +++------- crates/ironrdp-viewer/src/main.rs | 14 +- 12 files changed, 1199 insertions(+), 436 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce77fe08c8..b4858d7b54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2502,6 +2502,7 @@ version = "0.1.0" dependencies = [ "anyhow", "futures-util", + "ironrdp-cfg", "ironrdp-cliprdr", "ironrdp-cliprdr-native", "ironrdp-connector", @@ -2514,6 +2515,7 @@ dependencies = [ "ironrdp-graphics", "ironrdp-mstsgu", "ironrdp-pdu", + "ironrdp-propertyset", "ironrdp-rdcleanpath", "ironrdp-rdpdr", "ironrdp-rdpsnd", diff --git a/crates/ironrdp-cfg/src/lib.rs b/crates/ironrdp-cfg/src/lib.rs index b96b571d9d..d506cc2386 100644 --- a/crates/ironrdp-cfg/src/lib.rs +++ b/crates/ironrdp-cfg/src/lib.rs @@ -224,6 +224,71 @@ pub trait PropertySetExt { /// Target RDP server password - use for testing only fn clear_text_password(&self) -> Option<&str>; + + /// RDCleanPath proxy URL (IronRDP extension). + fn rdcleanpath_url(&self) -> Option<&str>; + + /// RDCleanPath authentication token (IronRDP extension) - secret, use for testing only. + fn rdcleanpath_token(&self) -> Option<&str>; + + /// DVC pipe proxy specifications (IronRDP extension). + /// + /// Comma-separated list of `=` entries. + fn dvc_pipe_proxies(&self) -> Option<&str>; + + /// Idle anti-lock fake events interval in minutes (IronRDP extension). + fn fake_events_interval(&self) -> Option; + + /// Enable RDPDR device redirection (IronRDP extension). + fn rdpdr_enabled(&self) -> Option; + + /// Enable smart-card redirection within RDPDR (IronRDP extension). + fn smartcard_enabled(&self) -> Option; + + /// Enable the QOI bitmap codec (IronRDP extension). + fn qoi_enabled(&self) -> Option; + + /// Enable the QOIZ bitmap codec (IronRDP extension). + fn qoiz_enabled(&self) -> Option; + + /// Enable TLS + graphical login (IronRDP extension; default enabled). + fn enable_tls(&self) -> Option; + + /// Render the server-side pointer (IronRDP extension; default enabled). + fn server_pointer(&self) -> Option; + + /// Automatically log on by passing the INFO_AUTOLOGON flag (IronRDP extension). + fn autologon(&self) -> Option; + + /// Bulk compression level (IronRDP extension): 0=K8, 1=K64, 2=Rdp6, 3=Rdp61. + fn compression_level(&self) -> Option; + + /// Color depth in bits per pixel (IronRDP extension), e.g. 16 or 32. + fn color_depth(&self) -> Option; + + /// DVC client plugin DLL paths (IronRDP extension; Windows only). Comma-separated. + fn dvc_plugins(&self) -> Option<&str>; + + // --- Setters (mirror the getters above; write the same keys) --- + + fn set_enable_credssp_support(&mut self, enabled: bool); + fn set_compression(&mut self, enabled: bool); + fn set_enable_tls(&mut self, enabled: bool); + fn set_server_pointer(&mut self, enabled: bool); + fn set_autologon(&mut self, enabled: bool); + fn set_compression_level(&mut self, level: u32); + fn set_color_depth(&mut self, depth: u32); + fn set_fake_events_interval(&mut self, minutes: u32); + fn set_dvc_plugins(&mut self, value: impl Into); + fn set_dvc_pipe_proxies(&mut self, value: impl Into); + fn set_rdcleanpath_url(&mut self, value: impl Into); + fn set_rdcleanpath_token(&mut self, value: impl Into); + fn clear_rdcleanpath(&mut self); + fn set_gateway_hostname(&mut self, value: impl Into); + fn set_gateway_usage_method(&mut self, method: GatewayUsageMethod); + fn set_gateway_credentials(&mut self, username: impl Into, password: impl Into); + fn clear_gateway(&mut self); + fn set_kdc_proxy_url(&mut self, value: impl Into); } impl PropertySetExt for PropertySet { @@ -338,4 +403,138 @@ impl PropertySetExt for PropertySet { fn clear_text_password(&self) -> Option<&str> { self.get::<&str>("ClearTextPassword") } + + fn rdcleanpath_url(&self) -> Option<&str> { + self.get::<&str>("ironrdp_rdcleanpathurl") + } + + fn rdcleanpath_token(&self) -> Option<&str> { + self.get::<&str>("ironrdp_rdcleanpathtoken") + } + + fn dvc_pipe_proxies(&self) -> Option<&str> { + self.get::<&str>("ironrdp_dvcpipeproxy") + } + + fn fake_events_interval(&self) -> Option { + self.get::("ironrdp_fakeeventsinterval") + } + + fn rdpdr_enabled(&self) -> Option { + self.get::("ironrdp_rdpdr") + } + + fn smartcard_enabled(&self) -> Option { + self.get::("ironrdp_smartcard") + } + + fn qoi_enabled(&self) -> Option { + self.get::("ironrdp_qoi") + } + + fn qoiz_enabled(&self) -> Option { + self.get::("ironrdp_qoiz") + } + + fn enable_tls(&self) -> Option { + self.get::("ironrdp_tls") + } + + fn server_pointer(&self) -> Option { + self.get::("ironrdp_serverpointer") + } + + fn autologon(&self) -> Option { + self.get::("ironrdp_autologon") + } + + fn compression_level(&self) -> Option { + self.get::("ironrdp_compressionlevel") + } + + fn color_depth(&self) -> Option { + self.get::("ironrdp_colordepth") + } + + fn dvc_plugins(&self) -> Option<&str> { + self.get::<&str>("ironrdp_dvcplugin") + } + + fn set_enable_credssp_support(&mut self, enabled: bool) { + self.insert("enablecredsspsupport", i64::from(enabled)); + } + + fn set_compression(&mut self, enabled: bool) { + self.insert("compression", enabled); + } + + fn set_enable_tls(&mut self, enabled: bool) { + self.insert("ironrdp_tls", enabled); + } + + fn set_server_pointer(&mut self, enabled: bool) { + self.insert("ironrdp_serverpointer", enabled); + } + + fn set_autologon(&mut self, enabled: bool) { + self.insert("ironrdp_autologon", enabled); + } + + fn set_compression_level(&mut self, level: u32) { + self.insert("ironrdp_compressionlevel", i64::from(level)); + } + + fn set_color_depth(&mut self, depth: u32) { + self.insert("ironrdp_colordepth", i64::from(depth)); + } + + fn set_fake_events_interval(&mut self, minutes: u32) { + self.insert("ironrdp_fakeeventsinterval", i64::from(minutes)); + } + + fn set_dvc_plugins(&mut self, value: impl Into) { + self.insert("ironrdp_dvcplugin", value.into()); + } + + fn set_dvc_pipe_proxies(&mut self, value: impl Into) { + self.insert("ironrdp_dvcpipeproxy", value.into()); + } + + fn set_rdcleanpath_url(&mut self, value: impl Into) { + self.insert("ironrdp_rdcleanpathurl", value.into()); + } + + fn set_rdcleanpath_token(&mut self, value: impl Into) { + self.insert("ironrdp_rdcleanpathtoken", value.into()); + } + + fn clear_rdcleanpath(&mut self) { + self.remove("ironrdp_rdcleanpathurl"); + self.remove("ironrdp_rdcleanpathtoken"); + } + + fn set_gateway_hostname(&mut self, value: impl Into) { + self.insert("gatewayhostname", value.into()); + } + + fn set_gateway_usage_method(&mut self, method: GatewayUsageMethod) { + self.insert("gatewayusagemethod", method.as_i64()); + } + + fn set_gateway_credentials(&mut self, username: impl Into, password: impl Into) { + self.insert("gatewayusername", username.into()); + self.insert("gatewaypassword", password.into()); + } + + fn clear_gateway(&mut self) { + self.remove("gatewayhostname"); + self.remove("gatewayusagemethod"); + self.remove("gatewayusername"); + self.remove("gatewaypassword"); + self.remove("GatewayPassword"); + } + + fn set_kdc_proxy_url(&mut self, value: impl Into) { + self.insert("kdcproxyurl", value.into()); + } } diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index f4cce16c1c..9794fb7408 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -67,6 +67,8 @@ ironrdp-echo = { path = "../ironrdp-echo", version = "0.3" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.9", features = ["reqwest"] } ironrdp-rdcleanpath = { path = "../ironrdp-rdcleanpath" } +ironrdp-cfg = { path = "../ironrdp-cfg" } +ironrdp-propertyset = { path = "../ironrdp-propertyset" } # Optional protocol crates (activated by features above) ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6", optional = true } diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 2bc41d953b..6b9ef67990 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -6,12 +6,14 @@ use std::path::PathBuf; use std::sync::Arc; use anyhow::Context as _; +use ironrdp_cfg::PropertySetExt as _; +use ironrdp_propertyset::PropertySet; use url::Url; // ── Extension registry ──────────────────────────────────────────────────────── -type StaticChannelFn = Arc; -type DvcChannelFn = Arc; +type StaticChannelFn = Arc; +type DvcChannelFn = Arc; /// Private registry of user-supplied static and dynamic virtual channel factories. /// @@ -47,37 +49,88 @@ impl fmt::Debug for ExtensionRegistry { /// This is the typed surface consumed by [`crate::rdp::RdpClient`]. Build it with /// [`ConfigBuilder`]; producing a `Config` from CLI arguments, `.rdp` files, or interactive /// prompts is the consumer's responsibility (see `ironrdp-viewer` for a reference front-end). +/// +/// The struct is opaque: fields are read-only via accessors so a built `Config` cannot drift into +/// an inconsistent state (e.g. mutating the connector without updating the originating +/// [`PropertySet`]). #[derive(Clone)] -#[expect( - clippy::partial_pub_fields, - reason = "extensions must stay crate-private because its type ExtensionRegistry is pub(crate)" -)] pub struct Config { - pub connector: ironrdp_connector::Config, - pub destination: Destination, - pub transport: Transport, - pub kerberos_config: Option, - pub log_file: Option, - pub fake_events_interval: Option, - pub channels: ChannelConfig, + pub(crate) connector: ironrdp_connector::Config, + pub(crate) destination: Destination, + pub(crate) transport: Transport, + pub(crate) kerberos_config: Option, + pub(crate) fake_events_interval: Option, + pub(crate) channels: ChannelConfig, /// DVC channel ↔ named-pipe proxy configuration. /// /// Each entry causes IronRDP to forward that DVC channel's traffic to/from the /// named pipe, allowing out-of-process DVC logic. #[cfg(feature = "dvc-pipe-proxy")] - pub dvc_pipe_proxies: Vec, + pub(crate) dvc_pipe_proxies: Vec, /// Paths to DVC client plugin DLLs to load (Windows only). /// /// Each DLL is loaded via `LoadLibraryW` and its `VirtualChannelGetInstance` export is /// called to obtain DVC plugin COM objects. Example: `C:\Windows\System32\webauthn.dll`. #[cfg(all(windows, feature = "dvc-com-plugin"))] - pub dvc_plugins: Vec, + pub(crate) dvc_plugins: Vec, + + /// The merged PropertySet that produced this config, shared (read-only) with channel factories. + pub(crate) properties: PropertySet, pub(crate) extensions: ExtensionRegistry, } +impl Config { + /// Connector configuration handed to the RDP connection sequence. + pub fn connector(&self) -> &ironrdp_connector::Config { + &self.connector + } + + /// Resolved RDP target (host + port). + pub fn destination(&self) -> &Destination { + &self.destination + } + + /// Selected transport (Direct, Gateway, or RDCleanPath). + pub fn transport(&self) -> &Transport { + &self.transport + } + + /// Optional Kerberos/KDC proxy configuration. + pub fn kerberos_config(&self) -> Option<&ironrdp_connector::credssp::KerberosConfig> { + self.kerberos_config.as_ref() + } + + /// Idle anti-lock fake-events interval, if enabled. + pub fn fake_events_interval(&self) -> Option { + self.fake_events_interval + } + + /// Channel/codec runtime toggles. + pub fn channels(&self) -> &ChannelConfig { + &self.channels + } + + /// DVC named-pipe proxy mappings. + #[cfg(feature = "dvc-pipe-proxy")] + pub fn dvc_pipe_proxies(&self) -> &[DvcProxyInfo] { + &self.dvc_pipe_proxies + } + + /// DVC client plugin DLL paths (Windows only). + #[cfg(all(windows, feature = "dvc-com-plugin"))] + pub fn dvc_plugins(&self) -> &[PathBuf] { + &self.dvc_plugins + } + + /// Merged `.rdp` PropertySet that produced this config. + pub fn properties(&self) -> &PropertySet { + &self.properties + } +} + impl fmt::Debug for Config { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut s = f.debug_struct("Config"); @@ -85,7 +138,6 @@ impl fmt::Debug for Config { s.field("destination", &self.destination); s.field("transport", &self.transport); s.field("kerberos_config", &self.kerberos_config); - s.field("log_file", &self.log_file); s.field("fake_events_interval", &self.fake_events_interval); s.field("channels", &self.channels); #[cfg(feature = "dvc-pipe-proxy")] @@ -109,6 +161,12 @@ pub enum ClipboardType { /// Disable clipboard redirection entirely. Disable, /// Use a stub clipboard backend (for testing or headless usage). + // FIXME: the `Stub` concept arguably shouldn't live in ironrdp-client. Investigate whether it + // can move out via the extension/backend API, so the stub backend stays in ironrdp-viewer as a + // debugging tool. Note that other consumers (e.g. ironrdp-agent) may need their own custom + // backend that is not integrated with the host system's clipboard either; the design should + // accommodate plugging in arbitrary CliprdrBackendFactory implementations rather than baking + // specific variants into the client. Stub, } @@ -190,9 +248,10 @@ impl Default for RdpdrConfig { } /// Transport selection for the RDP connection. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub enum Transport { /// Plain TCP → TLS direct connection to the RDP server. + #[default] Direct, /// Connect via an RDS gateway (MS-TSGU / MSTSGU). @@ -349,8 +408,59 @@ impl FromStr for DvcProxyInfo { // ── ConfigBuilder ───────────────────────────────────────────────────────────── +const RDP_DEFAULT_PORT: u16 = 3389; +const DEFAULT_WIDTH: u16 = 1280; +const DEFAULT_HEIGHT: u16 = 720; + +/// A configuration value that the consumer must supply before [`ConfigBuilder::build`] can succeed. +/// +/// Query the outstanding ones with [`ConfigBuilder::missing`], resolve each (prompt the user, or +/// derive a value), set it via the matching `with_*` method, then build. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MissingField { + /// Target server address (host[:port]). + ServerAddress, + /// RDP account user name. + Username, + /// RDP account password. + Password, + /// Gateway user name (only when a gateway transport is selected). + GatewayUsername, + /// Gateway password (only when a gateway transport is selected). + GatewayPassword, + /// Client build number (frontend-derived). + ClientBuild, + /// Client directory path (frontend-derived). + ClientDir, + /// Client platform (frontend-derived). + Platform, + /// Client computer name (frontend-derived). + ClientName, +} + +impl fmt::Display for MissingField { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::ServerAddress => "server address", + Self::Username => "username", + Self::Password => "password", + Self::GatewayUsername => "gateway username", + Self::GatewayPassword => "gateway password", + Self::ClientBuild => "client build", + Self::ClientDir => "client dir", + Self::Platform => "platform", + Self::ClientName => "client name", + }; + f.write_str(s) + } +} + /// Builder for [`Config`]. /// +/// No defaults are created up-front for required values; they are tracked as unset until provided. +/// Truly optional settings receive sensible defaults inside [`build`](ConfigBuilder::build). Use +/// [`missing`](ConfigBuilder::missing) to discover which required fields still need a value. +/// /// # Duplicate-channel behaviour /// /// * **Static channels** are keyed by the concrete processor `TypeId`; registering two factories @@ -358,51 +468,258 @@ impl FromStr for DvcProxyInfo { /// [`ironrdp_connector::ClientConnector::attach_static_channel`]. /// * **DVC channels** are keyed by channel name; duplicate names follow /// [`ironrdp_dvc::DrdynvcClient`]'s overwrite semantics. +/// +/// # Custom-channel configuration keys +/// +/// Factory closures registered with [`with_static_channel`](Self::with_static_channel) and +/// [`with_dvc`](Self::with_dvc) receive the merged [`PropertySet`], so a custom channel can read +/// its own settings (enabled/disabled, endpoints, flags) straight from the `.rdp` file. Which keys +/// to read is entirely up to the channel: there is no enforced naming scheme. By convention, +/// IronRDP's own extension keys use an `ironrdp_` prefix to avoid colliding with standard Microsoft +/// keys, and custom channels are encouraged (but not required) to namespace their keys similarly +/// (e.g. `mycorp_mychannel_enabled`). A channel may equally reuse a standard MS key when that fits, +/// or adopt a completely different pattern if warranted — these are only conventions. +#[derive(Default)] pub struct ConfigBuilder { - config: Config, + // Required (no default). + destination: Option, + username: Option, + password: Option, + client_build: Option, + client_dir: Option, + client_name: Option, + platform: Option, + gateway_username: Option, + gateway_password: Option, + + // Optional (defaulted at build time). + domain: Option, + enable_tls: Option, + enable_credssp: Option, + keyboard_type: Option, + keyboard_subtype: Option, + keyboard_functional_keys_count: Option, + ime_file_name: Option, + dig_product_id: Option, + desktop_width: Option, + desktop_height: Option, + desktop_scale_factor: Option, + color_depth: Option, + codecs: Vec, + autologon: Option, + enable_server_pointer: Option, + enable_audio_playback: Option, + compression_type: Option, + compression_enabled: Option, + alternate_shell: Option, + work_dir: Option, + + transport: Transport, + kerberos_config: Option, + fake_events_interval: Option, + channels: ChannelConfig, + #[cfg(feature = "dvc-pipe-proxy")] + dvc_pipe_proxies: Vec, + #[cfg(all(windows, feature = "dvc-com-plugin"))] + dvc_plugins: Vec, + properties: PropertySet, + extensions: ExtensionRegistry, } impl ConfigBuilder { - pub fn new(connector: ironrdp_connector::Config, destination: Destination) -> Self { - Self { - config: Config { - connector, - destination, - transport: Transport::Direct, - kerberos_config: None, - log_file: None, - fake_events_interval: None, - channels: ChannelConfig::default(), - #[cfg(feature = "dvc-pipe-proxy")] - dvc_pipe_proxies: Vec::new(), - #[cfg(all(windows, feature = "dvc-com-plugin"))] - dvc_plugins: Vec::new(), - extensions: ExtensionRegistry::default(), - }, - } + pub fn new() -> Self { + Self::default() } #[must_use] - pub fn with_transport(mut self, transport: Transport) -> Self { - self.config.transport = transport; + pub fn with_destination(mut self, destination: Destination) -> Self { + self.destination = Some(destination); self } #[must_use] - pub fn with_kerberos_config(mut self, cfg: ironrdp_connector::credssp::KerberosConfig) -> Self { - self.config.kerberos_config = Some(cfg); + pub fn with_credentials(mut self, username: impl Into, password: impl Into) -> Self { + self.username = Some(username.into()); + self.password = Some(password.into()); + self + } + + #[must_use] + pub fn with_username(mut self, username: impl Into) -> Self { + self.username = Some(username.into()); + self + } + + #[must_use] + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + #[must_use] + pub fn with_gateway_credentials(mut self, username: impl Into, password: impl Into) -> Self { + self.gateway_username = Some(username.into()); + self.gateway_password = Some(password.into()); + self + } + + #[must_use] + pub fn with_gateway_username(mut self, username: impl Into) -> Self { + self.gateway_username = Some(username.into()); + self + } + + #[must_use] + pub fn with_gateway_password(mut self, password: impl Into) -> Self { + self.gateway_password = Some(password.into()); + self + } + + #[must_use] + pub fn with_client_build(mut self, build: u32) -> Self { + self.client_build = Some(build); + self + } + + #[must_use] + pub fn with_client_dir(mut self, dir: impl Into) -> Self { + self.client_dir = Some(dir.into()); + self + } + + #[must_use] + pub fn with_client_name(mut self, name: impl Into) -> Self { + self.client_name = Some(name.into()); + self + } + + #[must_use] + pub fn with_platform(mut self, platform: ironrdp_pdu::rdp::capability_sets::MajorPlatformType) -> Self { + self.platform = Some(platform); + self + } + + #[must_use] + pub fn with_keyboard_type(mut self, ty: ironrdp_pdu::gcc::KeyboardType) -> Self { + self.keyboard_type = Some(ty); + self + } + + #[must_use] + pub fn with_keyboard_subtype(mut self, subtype: u32) -> Self { + self.keyboard_subtype = Some(subtype); + self + } + + #[must_use] + pub fn with_keyboard_functional_keys_count(mut self, count: u32) -> Self { + self.keyboard_functional_keys_count = Some(count); + self + } + + #[must_use] + pub fn with_ime_file_name(mut self, name: impl Into) -> Self { + self.ime_file_name = Some(name.into()); + self + } + + #[must_use] + pub fn with_dig_product_id(mut self, id: impl Into) -> Self { + self.dig_product_id = Some(id.into()); + self + } + + #[must_use] + pub fn with_color_depth(mut self, depth: u32) -> Self { + self.color_depth = Some(depth); + self.properties.set_color_depth(depth); + self + } + + /// Set the bitmap codecs (e.g. `["remotefx:on"]`). Not reflected in the PropertySet. + #[must_use] + pub fn with_codecs(mut self, codecs: Vec) -> Self { + self.codecs = codecs; + self + } + + #[must_use] + pub fn with_autologon(mut self, enabled: bool) -> Self { + self.autologon = Some(enabled); + self.properties.set_autologon(enabled); + self + } + + #[must_use] + pub fn with_enable_tls(mut self, enabled: bool) -> Self { + self.enable_tls = Some(enabled); + self.properties.set_enable_tls(enabled); self } #[must_use] - pub fn with_log_file(mut self, path: impl Into) -> Self { - self.config.log_file = Some(path.into()); + pub fn with_server_pointer(mut self, enabled: bool) -> Self { + self.enable_server_pointer = Some(enabled); + self.properties.set_server_pointer(enabled); + self + } + + /// Set the bulk compression type directly. Upserts the `ironrdp_compressionlevel` property. + #[must_use] + pub fn with_compression_type(mut self, ty: Option) -> Self { + self.compression_type = ty; + if let Some(ty) = ty { + self.properties.set_compression_level(level_from_compression_type(ty)); + } + self + } + + /// Set the transport. Upserts the corresponding properties (`ironrdp_rdcleanpathurl`/token, + /// `gatewayhostname`/usage/credentials), clearing the others so the PropertySet stays consistent. + #[must_use] + pub fn with_transport(mut self, transport: Transport) -> Self { + match &transport { + Transport::Direct => { + self.properties.clear_rdcleanpath(); + #[cfg(feature = "gateway")] + self.properties.clear_gateway(); + } + Transport::RDCleanPath(rdcp) => { + self.properties.set_rdcleanpath_url(rdcp.url.to_string()); + self.properties.set_rdcleanpath_token(rdcp.auth_token.clone()); + #[cfg(feature = "gateway")] + self.properties.clear_gateway(); + } + #[cfg(feature = "gateway")] + Transport::Gateway(gw) => { + self.properties.clear_rdcleanpath(); + self.properties.set_gateway_hostname(gw.endpoint.clone()); + self.properties + .set_gateway_usage_method(ironrdp_cfg::GatewayUsageMethod::UseAlways); + self.properties + .set_gateway_credentials(gw.username.clone(), gw.password.clone()); + } + } + self.transport = transport; + self + } + + /// Set the kerberos config. Upserts the `kdcproxyurl` property; `hostname` is derived from the + /// client name and not stored separately. + #[must_use] + pub fn with_kerberos_config(mut self, cfg: ironrdp_connector::credssp::KerberosConfig) -> Self { + if let Some(url) = &cfg.kdc_proxy_url { + self.properties.set_kdc_proxy_url(url.to_string()); + } + self.kerberos_config = Some(cfg); self } #[must_use] pub fn with_fake_events_interval(mut self, interval: Duration) -> Self { - self.config.fake_events_interval = Some(interval); + self.fake_events_interval = Some(interval); + self.properties + .set_fake_events_interval(u32::try_from(interval.as_secs() / 60).unwrap_or(u32::MAX)); self } @@ -410,7 +727,7 @@ impl ConfigBuilder { #[cfg(feature = "sound")] #[must_use] pub fn with_sound(mut self, enabled: bool) -> Self { - self.config.channels.sound = enabled; + self.channels.sound = enabled; self } @@ -418,7 +735,7 @@ impl ConfigBuilder { #[cfg(feature = "clipboard")] #[must_use] pub fn with_clipboard(mut self, mode: ClipboardType) -> Self { - self.config.channels.clipboard = mode; + self.channels.clipboard = mode; self } @@ -426,7 +743,7 @@ impl ConfigBuilder { #[cfg(feature = "rdpdr")] #[must_use] pub fn with_rdpdr(mut self, enabled: bool) -> Self { - self.config.channels.rdpdr.enabled = enabled; + self.channels.rdpdr.enabled = enabled; self } @@ -434,7 +751,7 @@ impl ConfigBuilder { #[cfg(feature = "smartcard")] #[must_use] pub fn with_smartcard(mut self, enabled: bool) -> Self { - self.config.channels.rdpdr.smartcard = enabled; + self.channels.rdpdr.smartcard = enabled; self } @@ -442,7 +759,7 @@ impl ConfigBuilder { #[cfg(feature = "qoi")] #[must_use] pub fn with_qoi(mut self, enabled: bool) -> Self { - self.config.channels.qoi = enabled; + self.channels.qoi = enabled; self } @@ -450,7 +767,7 @@ impl ConfigBuilder { #[cfg(feature = "qoiz")] #[must_use] pub fn with_qoiz(mut self, enabled: bool) -> Self { - self.config.channels.qoiz = enabled; + self.channels.qoiz = enabled; self } @@ -458,7 +775,7 @@ impl ConfigBuilder { #[cfg(feature = "dvc-pipe-proxy")] #[must_use] pub fn with_dvc_pipe_proxy(mut self, info: DvcProxyInfo) -> Self { - self.config.dvc_pipe_proxies.push(info); + self.dvc_pipe_proxies.push(info); self } @@ -466,43 +783,427 @@ impl ConfigBuilder { #[cfg(all(windows, feature = "dvc-com-plugin"))] #[must_use] pub fn with_dvc_plugin(mut self, path: impl Into) -> Self { - self.config.dvc_plugins.push(path.into()); + self.dvc_plugins.push(path.into()); self } /// Register a factory for a user-defined static virtual channel. /// - /// `factory` is called once per connection attempt to create a fresh channel instance. - /// Duplicate processor types follow `attach_static_channel` overwrite semantics. + /// `factory` is called once per connection attempt with the shared (read-only) [`PropertySet`], + /// so the channel can parametrize itself from the standard frontend config. Return `None` to + /// disable the channel. Duplicate processor types follow `attach_static_channel` overwrite semantics. #[must_use] pub fn with_static_channel(mut self, factory: F) -> Self where - F: Fn() -> P + Send + Sync + 'static, + F: Fn(&PropertySet) -> Option

+ Send + Sync + 'static, P: ironrdp_svc::SvcClientProcessor + 'static, { - let cb: StaticChannelFn = Arc::new(move |connector: &mut ironrdp_connector::ClientConnector| { - connector.attach_static_channel(factory()) + let cb: StaticChannelFn = Arc::new(move |connector: &mut ironrdp_connector::ClientConnector, ps| { + if let Some(processor) = factory(ps) { + connector.attach_static_channel(processor); + } }); - self.config.extensions.static_channels.push(cb); + self.extensions.static_channels.push(cb); self } /// Register a factory for a user-defined dynamic virtual channel. /// - /// `factory` is called once per connection attempt to create a fresh channel instance. - /// Duplicate channel names follow `DrdynvcClient` overwrite semantics. + /// `factory` is called once per connection attempt with the shared (read-only) [`PropertySet`], + /// so the channel can parametrize itself from the standard frontend config. Return `None` to + /// disable the channel. Duplicate channel names follow `DrdynvcClient` overwrite semantics. #[must_use] pub fn with_dvc(mut self, factory: F) -> Self where - F: Fn() -> P + Send + Sync + 'static, + F: Fn(&PropertySet) -> Option

+ Send + Sync + 'static, P: ironrdp_dvc::DvcProcessor + 'static, { - let cb: DvcChannelFn = Arc::new(move |drdynvc| drdynvc.attach_dynamic_channel(factory())); - self.config.extensions.dvc_channels.push(cb); + let cb: DvcChannelFn = Arc::new(move |drdynvc, ps| { + if let Some(processor) = factory(ps) { + drdynvc.attach_dynamic_channel(processor); + } + }); + self.extensions.dvc_channels.push(cb); self } - pub fn build(self) -> Config { - self.config + /// List the required fields that still need a value before [`build`](Self::build) can succeed. + /// + /// Gateway credentials are only required when a gateway transport is selected. + pub fn missing(&self) -> Vec { + let mut missing = Vec::new(); + if self.destination.is_none() { + missing.push(MissingField::ServerAddress); + } + if self.username.is_none() { + missing.push(MissingField::Username); + } + if self.password.is_none() { + missing.push(MissingField::Password); + } + #[cfg(feature = "gateway")] + if matches!(self.transport, Transport::Gateway(_)) { + if self.gateway_username.is_none() { + missing.push(MissingField::GatewayUsername); + } + if self.gateway_password.is_none() { + missing.push(MissingField::GatewayPassword); + } + } + if self.client_build.is_none() { + missing.push(MissingField::ClientBuild); + } + if self.client_dir.is_none() { + missing.push(MissingField::ClientDir); + } + if self.platform.is_none() { + missing.push(MissingField::Platform); + } + if self.client_name.is_none() { + missing.push(MissingField::ClientName); + } + missing } + + /// Build the [`Config`], filling optional settings with sensible defaults. + /// + /// Fails if any required field is unset; inspect [`missing`](Self::missing) beforehand to resolve them. + pub fn build(self) -> anyhow::Result { + use ironrdp_pdu::rdp::capability_sets::client_codecs_capabilities; + use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; + + let missing = self.missing(); + if !missing.is_empty() { + anyhow::bail!( + "missing required configuration: {}", + missing + .iter() + .map(MissingField::to_string) + .collect::>() + .join(", ") + ); + } + + let codecs: Vec<&str> = self.codecs.iter().map(String::as_str).collect(); + let codecs = client_codecs_capabilities(&codecs).map_err(|help| anyhow::anyhow!("{help}"))?; + let color_depth = self.color_depth.unwrap_or(32); + if color_depth != 16 && color_depth != 32 { + anyhow::bail!("invalid color depth: only 16 and 32 bit color depths are supported"); + } + let bitmap = ironrdp_connector::BitmapConfig { + color_depth, + lossy_compression: true, + codecs, + }; + + #[cfg_attr(not(feature = "gateway"), allow(unused_mut))] + let mut transport = self.transport; + #[cfg(feature = "gateway")] + if let Transport::Gateway(gw) = &mut transport { + gw.username = self.gateway_username.unwrap_or_default(); + gw.password = self.gateway_password.unwrap_or_default(); + } + + let client_name = self.client_name.unwrap_or_default(); + let kerberos_config = self + .kerberos_config + .or_else(|| kerberos_config_from_properties(&self.properties, &client_name)); + + // Bulk compression is enabled by default. We default to MPPC 64K (RDP5) rather than the + // richer XCRUSH (RDP6.1) because it is the most universally supported and lowest-state + // codec, and FastPath decompression is the only fully wired path. + // FIXME: bump the default to RDP6.1 (XCRUSH) once slow-path bulk decompression is wired + // (see ironrdp-session x224 path); until then a stateful codec risks silent corruption. + let compression_type = if self.compression_enabled.unwrap_or(true) { + Some( + self.compression_type + .unwrap_or(ironrdp_pdu::rdp::client_info::CompressionType::K64), + ) + } else { + None + }; + + let connector = ironrdp_connector::Config { + credentials: ironrdp_connector::Credentials::UsernamePassword { + username: self.username.unwrap_or_default(), + password: self.password.unwrap_or_default(), + }, + domain: self.domain, + enable_tls: self.enable_tls.unwrap_or(true), + enable_credssp: self.enable_credssp.unwrap_or(true), + keyboard_type: self + .keyboard_type + .unwrap_or(ironrdp_pdu::gcc::KeyboardType::IbmEnhanced), + keyboard_subtype: self.keyboard_subtype.unwrap_or(0), + keyboard_layout: 0, + keyboard_functional_keys_count: self.keyboard_functional_keys_count.unwrap_or(12), + ime_file_name: self.ime_file_name.unwrap_or_default(), + dig_product_id: self.dig_product_id.unwrap_or_default(), + desktop_size: ironrdp_connector::DesktopSize { + width: self.desktop_width.unwrap_or(DEFAULT_WIDTH), + height: self.desktop_height.unwrap_or(DEFAULT_HEIGHT), + }, + desktop_scale_factor: self.desktop_scale_factor.unwrap_or(0), + bitmap: Some(bitmap), + client_build: self.client_build.unwrap_or_default(), + client_name, + client_dir: self.client_dir.unwrap_or_default(), + platform: self + .platform + .unwrap_or(ironrdp_pdu::rdp::capability_sets::MajorPlatformType::UNSPECIFIED), + hardware_id: None, + license_cache: None, + enable_server_pointer: self.enable_server_pointer.unwrap_or(true), + autologon: self.autologon.unwrap_or(false), + enable_audio_playback: self.enable_audio_playback.unwrap_or(true), + request_data: None, + pointer_software_rendering: false, + multitransport_flags: None, + compression_type, + performance_flags: PerformanceFlags::default(), + timezone_info: TimezoneInfo::default(), + alternate_shell: self.alternate_shell.unwrap_or_default(), + work_dir: self.work_dir.unwrap_or_default(), + }; + + Ok(Config { + connector, + destination: self.destination.context("server address is required")?, + transport, + kerberos_config, + fake_events_interval: self.fake_events_interval, + channels: self.channels, + #[cfg(feature = "dvc-pipe-proxy")] + dvc_pipe_proxies: self.dvc_pipe_proxies, + #[cfg(all(windows, feature = "dvc-com-plugin"))] + dvc_plugins: self.dvc_plugins, + properties: self.properties, + extensions: self.extensions, + }) + } + + /// Build a [`Config`] from a `.rdp` [`PropertySet`], leaving anything not expressible as a + /// property unset (query [`missing`](Self::missing) to resolve the rest). + pub fn from_property_set(ps: &PropertySet) -> anyhow::Result { + ConfigBuilder::new().with_property_set(ps) + } + + /// Overlay a `.rdp` [`PropertySet`] on top of the current builder. + /// + /// Only properties present in `ps` set values, so this can be layered: + /// `explicit setters → PropertySet → more setters`, last writer wins. Resolution rules: + /// `full address` beats `alternate full address`, an embedded port beats `server port`, and + /// transport precedence is RDCleanPath > Gateway > Direct. + pub fn with_property_set(mut self, ps: &PropertySet) -> anyhow::Result { + #[cfg(feature = "gateway")] + use ironrdp_cfg::GatewayUsageMethod; + use ironrdp_cfg::{AudioMode, TargetHost}; + + self.properties.merge(ps); + + let target = ps.full_address().context("invalid 'full address'")?.or(ps + .alternate_full_address() + .context("invalid 'alternate full address'")?); + if let Some(target) = target { + let port = target + .port + .or(ps.server_port().context("invalid 'server port'")?) + .unwrap_or(RDP_DEFAULT_PORT); + let name = match target.host { + TargetHost::Ip(ip) => ip.to_string(), + TargetHost::Domain(host) => host, + }; + self.destination = Some(Destination::from_parts(name, port)); + } + + if let Some(username) = ps.username() { + self.username = Some(username.to_owned()); + } + if let Some(password) = ps.clear_text_password() { + self.password = Some(password.to_owned()); + } + if let Some(domain) = ps.domain() { + self.domain = Some(domain.to_owned()); + } + if let Some(enable_credssp) = ps.enable_credssp_support() { + self.enable_credssp = Some(enable_credssp); + } + if let Some(enable_tls) = ps.enable_tls() { + self.enable_tls = Some(enable_tls); + } + if let Some(server_pointer) = ps.server_pointer() { + self.enable_server_pointer = Some(server_pointer); + } + if let Some(autologon) = ps.autologon() { + self.autologon = Some(autologon); + } + if let Some(scale) = ps.desktop_scale_factor().ok().flatten() { + self.desktop_scale_factor = Some(scale); + } + if let Some(width) = ps.desktop_width().ok().flatten() { + self.desktop_width = Some(width); + } + if let Some(height) = ps.desktop_height().ok().flatten() { + self.desktop_height = Some(height); + } + if let Some(shell) = ps.alternate_shell() { + self.alternate_shell = Some(shell.to_owned()); + } + if let Some(dir) = ps.shell_working_directory() { + self.work_dir = Some(dir.to_owned()); + } + if let Some(minutes) = ps.fake_events_interval() { + self.fake_events_interval = Some(Duration::from_secs(u64::from(minutes) * 60)); + } + if let Some(level) = ps.compression_level() { + self.compression_type = Some(compression_type_from_level(level)?); + } + if let Some(enabled) = ps.compression() { + self.compression_enabled = Some(enabled); + } + if let Some(depth) = ps.color_depth() { + self.color_depth = Some(depth); + } + match ps.audio_mode() { + Ok(Some(AudioMode::PlayOnServer | AudioMode::Disabled)) => self.enable_audio_playback = Some(false), + Ok(Some(AudioMode::RedirectToClient)) => self.enable_audio_playback = Some(true), + _ => {} + } + + // Transport: RDCleanPath > Gateway > Direct. + if let Some((url, token)) = ps.rdcleanpath_url().zip(ps.rdcleanpath_token()) { + let url = Url::parse(url).context("invalid 'ironrdp_rdcleanpathurl'")?; + self.transport = Transport::RDCleanPath(RDCleanPathConfig { + url, + auth_token: token.to_owned(), + }); + } else { + #[cfg(feature = "gateway")] + { + let use_gateway = ps + .gateway_usage_method() + .ok() + .flatten() + .map_or(ps.gateway_hostname().is_some(), GatewayUsageMethod::is_gateway_required); + if let Some(endpoint) = use_gateway.then(|| ps.gateway_hostname()).flatten() { + self.transport = Transport::Gateway(GatewayConfig { + endpoint: endpoint.to_owned(), + username: String::new(), + password: String::new(), + }); + if let Some(user) = ps.gateway_username() { + self.gateway_username = Some(user.to_owned()); + } + if let Some(pass) = ps.gateway_password() { + self.gateway_password = Some(pass.to_owned()); + } + } + } + } + + if let Some(redirect) = ps.redirect_clipboard() { + #[cfg(feature = "clipboard")] + { + self.channels.clipboard = if redirect { + ClipboardType::Enable + } else { + ClipboardType::Disable + }; + } + let _ = redirect; + } + #[cfg(feature = "sound")] + if matches!(ps.audio_mode(), Ok(Some(AudioMode::Disabled))) { + self.channels.sound = false; + } + #[cfg(feature = "rdpdr")] + if let Some(enabled) = ps.rdpdr_enabled() { + self.channels.rdpdr.enabled = enabled; + } + #[cfg(feature = "smartcard")] + if let Some(enabled) = ps.smartcard_enabled() { + self.channels.rdpdr.smartcard = enabled; + } + #[cfg(feature = "qoi")] + if let Some(enabled) = ps.qoi_enabled() { + self.channels.qoi = enabled; + } + #[cfg(feature = "qoiz")] + if let Some(enabled) = ps.qoiz_enabled() { + self.channels.qoiz = enabled; + } + + #[cfg(feature = "dvc-pipe-proxy")] + for proxy in ps.dvc_pipe_proxies().into_iter().flat_map(|s| s.split(',')) { + let proxy = proxy.trim(); + if !proxy.is_empty() { + self.dvc_pipe_proxies + .push(proxy.parse().context("invalid DVC pipe proxy spec")?); + } + } + + #[cfg(all(windows, feature = "dvc-com-plugin"))] + for plugin in ps.dvc_plugins().into_iter().flat_map(|s| s.split(',')) { + let plugin = plugin.trim(); + if !plugin.is_empty() { + self.dvc_plugins.push(PathBuf::from(plugin)); + } + } + + Ok(self) + } +} + +/// Map a bulk-compression level (0–3) to the corresponding [`CompressionType`]. +/// +/// 0 = MPPC 8K (RDP4), 1 = MPPC 64K (RDP5), 2 = NCRUSH (RDP6), 3 = XCRUSH (RDP6.1). +/// +/// [`CompressionType`]: ironrdp_pdu::rdp::client_info::CompressionType +fn compression_type_from_level(level: u32) -> anyhow::Result { + use ironrdp_pdu::rdp::client_info::CompressionType; + + match level { + 0 => Ok(CompressionType::K8), + 1 => Ok(CompressionType::K64), + 2 => Ok(CompressionType::Rdp6), + 3 => Ok(CompressionType::Rdp61), + _ => anyhow::bail!("invalid compression level: valid values are 0, 1, 2, 3"), + } +} + +fn level_from_compression_type(ty: ironrdp_pdu::rdp::client_info::CompressionType) -> u32 { + use ironrdp_pdu::rdp::client_info::CompressionType; + + match ty { + CompressionType::K8 => 0, + CompressionType::K64 => 1, + CompressionType::Rdp6 => 2, + CompressionType::Rdp61 => 3, + } +} + +/// Derive a Kerberos/KDC-proxy config from `kdcproxyurl`/`kdcproxyname`, using `client_name` as the +/// SPN hostname. Returns `None` if no KDC proxy is configured or the URL is invalid. +fn kerberos_config_from_properties( + ps: &PropertySet, + client_name: &str, +) -> Option { + use ironrdp_cfg::PropertySetExt as _; + + let kdc_proxy_url = ps.kdc_proxy_url().map(str::to_owned).or_else(|| { + ps.kdc_proxy_name().map(|name| { + if name.starts_with("http://") || name.starts_with("https://") { + name.to_owned() + } else { + format!("https://{name}/KdcProxy") + } + }) + })?; + + Url::parse(&kdc_proxy_url) + .ok() + .map(|url| ironrdp_connector::credssp::KerberosConfig { + kdc_proxy_url: Some(url), + hostname: client_name.to_owned(), + }) } diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 0cb1534964..324bb9dfc9 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -1,5 +1,6 @@ use core::net::SocketAddr; use core::num::NonZeroU16; +use core::time::Duration; use std::sync::Arc; use ironrdp_connector::connection_activation::ConnectionActivationState; @@ -12,7 +13,9 @@ use ironrdp_dvc::DvcProcessor as _; use ironrdp_echo::client::EchoClient; use ironrdp_graphics::image_processing::PixelFormat; use ironrdp_graphics::pointer::DecodedPointer; +use ironrdp_pdu::input::MousePdu; use ironrdp_pdu::input::fast_path::FastPathInputEvent; +use ironrdp_pdu::input::mouse::PointerFlags; #[cfg(any(feature = "dvc-pipe-proxy", all(windows, feature = "dvc-com-plugin")))] use ironrdp_pdu::pdu_other_err; use ironrdp_session::image::DecodedImage; @@ -234,6 +237,7 @@ impl RdpClient { connection_result, &self.output_event_sender, &mut self.input_event_receiver, + self.config.fake_events_interval, ) .await { @@ -338,7 +342,7 @@ fn build_connector( // Attach user-defined DVC channels from the extension registry. for attach_dvc in &config.extensions.dvc_channels { - attach_dvc(&mut drdynvc); + attach_dvc(&mut drdynvc, &config.properties); } // Clone the connector config so we can apply runtime overrides before handing it to the @@ -411,7 +415,7 @@ fn build_connector( // Attach user-defined static channels from the extension registry. for attach_sc in &config.extensions.static_channels { - attach_sc(&mut connector); + attach_sc(&mut connector, &config.properties); } connector @@ -723,17 +727,24 @@ async fn active_session( connection_result: ConnectionResult, output_event_sender: &mpsc::Sender, input_event_receiver: &mut mpsc::UnboundedReceiver, + fake_events_interval: Option, ) -> SessionResult { let (mut reader, mut writer) = split_tokio_framed(framed); - let mut image = DecodedImage::new( - PixelFormat::RgbA32, - connection_result.desktop_size.width, - connection_result.desktop_size.height, - ); + let desktop_size = connection_result.desktop_size; + let mut image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); let mut active_stage = ActiveStage::new(connection_result); // Timer interval for driving clipboard lock timeouts. - let mut cleanup_interval = tokio::time::interval(core::time::Duration::from_secs(5)); + let mut cleanup_interval = tokio::time::interval(Duration::from_secs(5)); + + // Anti-idle: track the time of the last real input and the last known mouse position so we can + // synthesize a no-op mouse move when the session has been idle for too long. Default to the + // middle of the screen so a synthetic move before any real input doesn't snap the pointer to a + // corner. + let mut last_input = tokio::time::Instant::now(); + let mut last_mouse_pos = (desktop_size.width / 2, desktop_size.height / 2); + let mut fake_events_interval = + fake_events_interval.map(|interval| tokio::time::interval(core::cmp::max(interval, Duration::from_secs(1)))); let disconnect_reason = 'outer: loop { let outputs = tokio::select! { @@ -745,6 +756,8 @@ async fn active_session( input_event = input_event_receiver.recv() => { let input_event = input_event.ok_or_else(|| ironrdp_session::general_err!("GUI is stopped"))?; + last_input = tokio::time::Instant::now(); + match input_event { RdpInputEvent::Resize { width, height, scale_factor, physical_size } => { trace!(width, height, "Resize event"); @@ -767,6 +780,11 @@ async fn active_session( } RdpInputEvent::FastPath(events) => { trace!(?events); + for event in &events { + if let FastPathInputEvent::MouseEvent(mouse) = event { + last_mouse_pos = (mouse.x_position, mouse.y_position); + } + } active_stage.process_fastpath_input(&mut image, &events)? } RdpInputEvent::Close => { @@ -842,6 +860,26 @@ async fn active_session( #[cfg(not(feature = "clipboard"))] Vec::new() } + _ = async { match fake_events_interval.as_mut() { + Some(interval) => interval.tick().await, + None => core::future::pending().await, + }} => { + // Anti-idle: synthesize a no-op mouse move if the session has been idle for at least + // the configured interval, keeping the connection alive without user interaction. + if last_input.elapsed() >= fake_events_interval.as_ref().map_or(Duration::MAX, |i| i.period()) { + last_input = tokio::time::Instant::now(); + let mut events = SmallVec::<[FastPathInputEvent; 2]>::new(); + events.push(FastPathInputEvent::MouseEvent(MousePdu { + flags: PointerFlags::MOVE, + number_of_wheel_rotation_units: 0, + x_position: last_mouse_pos.0, + y_position: last_mouse_pos.1, + })); + active_stage.process_fastpath_input(&mut image, &events)? + } else { + Vec::new() + } + } }; for out in outputs { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs index 3143de072d..5d2089accb 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs @@ -739,6 +739,9 @@ fn parse_codecs_config<'a>(codecs: &'a [&'a str]) -> Result impl Iterator { self.inner.iter() } + + /// Merges all entries from `other` into this set, overwriting existing keys (last writer wins). + pub fn merge(&mut self, other: &PropertySet) { + for (key, value) in &other.inner { + self.inner.insert(key.clone(), value.clone()); + } + } } impl IntoIterator for PropertySet { diff --git a/crates/ironrdp-session/src/x224/mod.rs b/crates/ironrdp-session/src/x224/mod.rs index 529aa397da..079128e1be 100644 --- a/crates/ironrdp-session/src/x224/mod.rs +++ b/crates/ironrdp-session/src/x224/mod.rs @@ -221,6 +221,9 @@ impl Processor { // ClientInfoFlags::COMPRESSION is negotiated. Decompression // should happen here before passing data downstream. Currently // IronRDP does not wire bulk decompression into this path. + // FIXME: until this is wired, the client deliberately defaults to the simple, + // stateless-friendly MPPC 64K (RDP5) compression level rather than XCRUSH; a + // stateful codec would risk silent corruption on slow-path updates. ShareDataPdu::Update(data) => { debug!("Got slow-path graphics update ({} bytes)", data.len()); Ok(vec![ProcessorOutput::GraphicsUpdate(data)]) diff --git a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs index 9e262727ec..03a23dd603 100644 --- a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs +++ b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs @@ -48,7 +48,7 @@ fn gateway_is_disabled_when_gateway_usage_method_is_zero() { &[], ); - assert!(!matches!(config.transport, Transport::Gateway(_))); + assert!(!matches!(config.transport(), Transport::Gateway(_))); } #[test] @@ -58,7 +58,7 @@ fn gateway_is_disabled_when_gateway_usage_method_is_four() { &[], ); - assert!(!matches!(config.transport, Transport::Gateway(_))); + assert!(!matches!(config.transport(), Transport::Gateway(_))); } #[test] @@ -68,7 +68,7 @@ fn gateway_is_enabled_with_usage_method_one_and_file_credentials() { &[], ); - let Transport::Gateway(gw) = config.transport else { + let Transport::Gateway(gw) = config.transport() else { panic!("gateway should be configured"); }; assert_eq!(gw.endpoint, "gw.example.com:443"); @@ -83,7 +83,7 @@ fn no_credssp_cli_flag_overrides_rdp_enable_credssp_property() { &["--no-credssp"], ); - assert!(!config.connector.enable_credssp); + assert!(!config.connector().enable_credssp); } #[test] @@ -93,8 +93,11 @@ fn kdc_proxy_name_is_normalized_to_https_url() { &[], ); - let kerberos = config.kerberos_config.expect("kerberos config should be present"); - let kdc_proxy_url = kerberos.kdc_proxy_url.expect("kdc proxy url should be present"); + let kerberos = config.kerberos_config().expect("kerberos config should be present"); + let kdc_proxy_url = kerberos + .kdc_proxy_url + .as_ref() + .expect("kdc proxy url should be present"); assert_eq!(kdc_proxy_url.as_str(), "https://kdc.example.com/KdcProxy"); } @@ -105,7 +108,7 @@ fn redirectclipboard_zero_disables_clipboard_for_default_mode() { &[], ); - assert!(matches!(config.channels.clipboard, ClipboardType::Disable)); + assert!(matches!(config.channels().clipboard, ClipboardType::Disable)); } #[test] @@ -115,7 +118,7 @@ fn audiomode_two_disables_audio_playback() { &[], ); - assert!(!config.connector.enable_audio_playback); + assert!(!config.connector().enable_audio_playback); } #[test] @@ -125,7 +128,7 @@ fn invalid_audiomode_falls_back_to_audio_playback_enabled() { &[], ); - assert!(config.connector.enable_audio_playback); + assert!(config.connector().enable_audio_playback); } #[test] @@ -135,9 +138,9 @@ fn desktop_dimensions_are_parsed_from_rdp_file() { &[], ); - assert_eq!(config.connector.desktop_size.width, 1024); - assert_eq!(config.connector.desktop_size.height, 768); - assert_eq!(config.connector.desktop_scale_factor, 125); + assert_eq!(config.connector().desktop_size.width, 1024); + assert_eq!(config.connector().desktop_size.height, 768); + assert_eq!(config.connector().desktop_scale_factor, 125); } #[test] @@ -152,11 +155,11 @@ fn out_of_range_desktop_dimensions_fall_back_to_defaults() { ); assert_eq!( - invalid_config.connector.desktop_size.width, - default_config.connector.desktop_size.width + invalid_config.connector().desktop_size.width, + default_config.connector().desktop_size.width ); assert_eq!( - invalid_config.connector.desktop_size.height, - default_config.connector.desktop_size.height + invalid_config.connector().desktop_size.height, + default_config.connector().desktop_size.height ); } diff --git a/crates/ironrdp-viewer/src/app.rs b/crates/ironrdp-viewer/src/app.rs index a19703a011..ad3f47ed5b 100644 --- a/crates/ironrdp-viewer/src/app.rs +++ b/crates/ironrdp-viewer/src/app.rs @@ -7,9 +7,7 @@ use std::time::Instant; use anyhow::Context as _; use ironrdp::client::rdp::{RdpInputEvent, RdpOutputEvent}; -use ironrdp::pdu::input::MousePdu; use ironrdp::pdu::input::fast_path::FastPathInputEvent; -use ironrdp::pdu::input::mouse::PointerFlags; use raw_window_handle::{DisplayHandle, HasDisplayHandle as _}; use smallvec::SmallVec; use tokio::sync::mpsc; @@ -33,15 +31,12 @@ pub struct App { input_database: ironrdp::input::Database, last_size: Option>, resize_timeout: Option, - last_event: Option, - fake_events_interval: Option, } impl App { pub fn new( event_loop: &EventLoop, input_event_sender: &mpsc::UnboundedSender, - fake_events_interval: Option, initial_window_size: PhysicalSize, ) -> anyhow::Result { // SAFETY: We drop the softbuffer context right before the event loop is stopped, thus making this safe. @@ -65,8 +60,6 @@ impl App { input_database, last_size: None, resize_timeout: None, - last_event: None, - fake_events_interval, }) } @@ -106,30 +99,10 @@ impl App { sb_buffer.copy_from_slice(self.buffer.as_slice()); sb_buffer.present().expect("buffer present"); } - - pub fn fake_mouse_move(&mut self) { - let (Some(last_event), Some(fake_events_interval)) = (self.last_event, self.fake_events_interval) else { - return; - }; - - if last_event.elapsed() > fake_events_interval { - let mut events = SmallVec::new(); - let curr_pos = self.input_database.mouse_position(); - events.push(FastPathInputEvent::MouseEvent(MousePdu { - flags: PointerFlags::MOVE, - number_of_wheel_rotation_units: 0, - x_position: curr_pos.x, - y_position: curr_pos.y, - })); - let _ = self.input_event_sender.send(RdpInputEvent::FastPath(events)); - } - } } impl ApplicationHandler for App { fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - self.fake_mouse_move(); - if let Some(timeout) = self.resize_timeout { if let Some(timeout) = timeout.checked_duration_since(Instant::now()) { event_loop.set_control_flow(ControlFlow::wait_duration(timeout)); @@ -216,7 +189,7 @@ impl ApplicationHandler for App { let input_events = self.input_database.apply(core::iter::once(operation)); - send_fast_path_events(&self.input_event_sender, input_events, &mut self.last_event); + send_fast_path_events(&self.input_event_sender, input_events); } } WindowEvent::ModifiersChanged(modifiers) => { @@ -252,7 +225,7 @@ impl ApplicationHandler for App { let input_events = self.input_database.apply(operations); - send_fast_path_events(&self.input_event_sender, input_events, &mut self.last_event); + send_fast_path_events(&self.input_event_sender, input_events); } WindowEvent::CursorMoved { position, .. } => { let win_size = window.inner_size(); @@ -264,7 +237,7 @@ impl ApplicationHandler for App { let input_events = self.input_database.apply(core::iter::once(operation)); - send_fast_path_events(&self.input_event_sender, input_events, &mut self.last_event); + send_fast_path_events(&self.input_event_sender, input_events); } WindowEvent::MouseWheel { delta, .. } => { let mut operations = SmallVec::<[ironrdp::input::Operation; 2]>::new(); @@ -316,7 +289,7 @@ impl ApplicationHandler for App { let input_events = self.input_database.apply(operations); - send_fast_path_events(&self.input_event_sender, input_events, &mut self.last_event); + send_fast_path_events(&self.input_event_sender, input_events); } WindowEvent::MouseInput { state, button, .. } => { let mouse_button = match button { @@ -341,7 +314,7 @@ impl ApplicationHandler for App { let input_events = self.input_database.apply(core::iter::once(operation)); - send_fast_path_events(&self.input_event_sender, input_events, &mut self.last_event); + send_fast_path_events(&self.input_event_sender, input_events); } WindowEvent::RedrawRequested => { self.draw(); @@ -440,10 +413,8 @@ impl ApplicationHandler for App { fn send_fast_path_events( input_event_sender: &mpsc::UnboundedSender, input_events: SmallVec<[FastPathInputEvent; 2]>, - last_event: &mut Option, ) { if !input_events.is_empty() { let _ = input_event_sender.send(RdpInputEvent::FastPath(input_events)); } - *last_event = Some(Instant::now()); } diff --git a/crates/ironrdp-viewer/src/config.rs b/crates/ironrdp-viewer/src/config.rs index 73db599677..7909d76d0c 100644 --- a/crates/ironrdp-viewer/src/config.rs +++ b/crates/ironrdp-viewer/src/config.rs @@ -1,25 +1,19 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] use core::num::ParseIntError; -use core::time::Duration; use std::path::PathBuf; use anyhow::Context as _; use clap::Parser; use clap::clap_derive::ValueEnum; use ironrdp::client::config::{ - ClipboardType as ResolvedClipboardType, Config, ConfigBuilder, Destination, DvcProxyInfo, GatewayConfig, - RDCleanPathConfig, Transport, + ClipboardType as ResolvedClipboardType, Config, ConfigBuilder, Destination, DvcProxyInfo, MissingField, }; -use ironrdp::connector::{self, Credentials}; use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; -use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; +use ironrdp_cfg::PropertySetExt as _; use tap::prelude::*; use url::Url; -const DEFAULT_WIDTH: u16 = 1920; -const DEFAULT_HEIGHT: u16 = 1080; - /// CLI selection for the clipboard backend. /// /// Maps directly into the library's [`ResolvedClipboardType`] when the typed [`Config`] is built. @@ -111,23 +105,64 @@ fn apply_cli_args_to_properties(properties: &mut ironrdp_propertyset::PropertySe } if args.no_credssp { - properties.insert("enablecredsspsupport", 0i64); + properties.set_enable_credssp_support(false); + } + + if args.no_tls { + properties.set_enable_tls(false); + } + + if args.no_server_pointer { + properties.set_server_pointer(false); + } + + if args.autologon { + properties.set_autologon(true); } if let Some(enabled) = args.compression_enabled { - properties.insert("compression", enabled); + properties.set_compression(enabled); + } + + if let Some(level) = args.compression_level { + properties.set_compression_level(level); + } + + if let Some(color_depth) = args.color_depth { + properties.set_color_depth(color_depth); + } + + #[cfg(windows)] + if !args.dvc_plugin.is_empty() { + let value = args + .dvc_plugin + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(","); + properties.set_dvc_plugins(value); + } + + if let Some(url) = &args.rdcleanpath_url { + properties.set_rdcleanpath_url(url.as_str()); + } + + if let Some(token) = &args.rdcleanpath_token { + properties.set_rdcleanpath_token(token.as_str()); } -} -fn compression_type_from_level(level: u32) -> anyhow::Result { - use ironrdp::pdu::rdp::client_info::CompressionType; + if let Some(minutes) = args.prevent_session_lock { + properties.set_fake_events_interval(minutes); + } - match level { - 0 => Ok(CompressionType::K8), - 1 => Ok(CompressionType::K64), - 2 => Ok(CompressionType::Rdp6), - 3 => Ok(CompressionType::Rdp61), - _ => anyhow::bail!("Invalid compression level. Valid values are 0, 1, 2, 3."), + if !args.dvc_proxy.is_empty() { + let value = args + .dvc_proxy + .iter() + .map(|p| format!("{}={}", p.channel_name, p.pipe_name)) + .collect::>() + .join(","); + properties.set_dvc_pipe_proxies(value); } } @@ -280,8 +315,8 @@ struct Args { /// 1 — MPPC with 64 KB history (RDP 5.0) /// 2 — NCRUSH (RDP 6.0) /// 3 — XCRUSH (RDP 6.1) - #[clap(long, value_parser = clap::value_parser!(u32).range(0..=3), default_value_t = 3)] - compression_level: u32, + #[clap(long, value_parser = clap::value_parser!(u32).range(0..=3))] + compression_level: Option, /// Prevents session locking by injecting fake mouse movement events when /// the connection is idle (interval in minutes) @@ -324,7 +359,6 @@ pub struct PartialConfig { // CLI-only settings that are not representable as `.rdp` file properties. pub log_file: Option, pub dump_rdp: Option, - pub rdcleanpath: Option, pub keyboard_type: KeyboardType, pub keyboard_subtype: u32, pub keyboard_functional_keys_count: u32, @@ -332,18 +366,9 @@ pub struct PartialConfig { pub dig_product_id: String, pub thin_client: bool, pub small_cache: bool, - pub color_depth: Option, - pub no_server_pointer: bool, pub capabilities: u32, - pub autologon: bool, - pub no_tls: bool, pub clipboard_type: ClipboardType, pub codecs: Vec, - pub compression_level: u32, - pub prevent_session_lock: Option, - pub dvc_pipe_proxies: Vec, - #[cfg(windows)] - pub dvc_plugins: Vec, } impl PartialConfig { @@ -374,16 +399,10 @@ impl PartialConfig { // CLI arguments take precedence: upsert them after the .rdp file is loaded. apply_cli_args_to_properties(&mut properties, &args); - let rdcleanpath = args - .rdcleanpath_url - .zip(args.rdcleanpath_token) - .map(|(url, auth_token)| RDCleanPathConfig { url, auth_token }); - Ok(Self { properties, log_file: args.log_file, dump_rdp: args.dump_rdp, - rdcleanpath, keyboard_type: args.keyboard_type, keyboard_subtype: args.keyboard_subtype, keyboard_functional_keys_count: args.keyboard_functional_keys_count, @@ -391,285 +410,112 @@ impl PartialConfig { dig_product_id: args.dig_product_id, thin_client: args.thin_client, small_cache: args.small_cache, - color_depth: args.color_depth, - no_server_pointer: args.no_server_pointer, capabilities: args.capabilities, - autologon: args.autologon, - no_tls: args.no_tls, clipboard_type: args.clipboard_type, codecs: args.codecs, - compression_level: args.compression_level, - prevent_session_lock: args.prevent_session_lock, - dvc_pipe_proxies: args.dvc_proxy, - #[cfg(windows)] - dvc_plugins: args.dvc_plugin, }) } pub fn into_config(self) -> anyhow::Result { - use ironrdp_cfg::{AudioMode, PropertySetExt as _}; - - let properties = &self.properties; - - let has_gateway_host = properties.gateway_hostname().is_some(); - let use_gateway = properties - .gateway_usage_method() - .unwrap_or_else(|e| { - eprintln!("Warning: {e}, assuming no gateway"); - Some(ironrdp_cfg::GatewayUsageMethod::Direct) - }) - .map_or(has_gateway_host, ironrdp_cfg::GatewayUsageMethod::is_gateway_required); - - let mut gw_config: Option = - use_gateway - .then(|| properties.gateway_hostname()) - .flatten() - .map(|gw_addr| GatewayConfig { - endpoint: gw_addr.to_owned(), - username: String::new(), - password: String::new(), - }); - - if let Some(ref mut gw) = gw_config { - if let Ok(Some(gateway_credentials_source)) = properties.gateway_credentials_source() { - // All known credential sources fall through to username/password prompts. - // The value is available for future differentiation if needed. - let _ = gateway_credentials_source; - } + use ironrdp_cfg::PropertySetExt as _; + + // The library overlays everything expressible as a `.rdp` property: destination, credentials, + // transport, channels, desktop size, audio, DVC proxies, etc. + let mut builder = ConfigBuilder::from_property_set(&self.properties)?; + + // CLI-only knobs that are not representable as `.rdp` properties. + builder = builder + .with_keyboard_type(self.keyboard_type.into_pdu()) + .with_keyboard_subtype(self.keyboard_subtype) + .with_keyboard_functional_keys_count(self.keyboard_functional_keys_count) + .with_ime_file_name(self.ime_file_name) + .with_dig_product_id(self.dig_product_id) + .with_codecs(self.codecs.clone()); + + // Validate the codecs early to surface help text before connecting. + let codecs: Vec<_> = self.codecs.iter().map(String::as_str).collect(); + if let Err(help) = client_codecs_capabilities(&codecs) { + print!("{help}"); + std::process::exit(0); + } - gw.username = if let Some(gw_user) = properties.gateway_username() { - gw_user.to_owned() - } else { - inquire::Text::new("Gateway username:") - .prompt() - .context("Username prompt")? - }; + let redirect_clipboard = self.properties.redirect_clipboard().unwrap_or(true); + builder = builder.with_clipboard(resolve_clipboard_type(self.clipboard_type, redirect_clipboard)); - gw.password = if let Some(gw_pass) = properties.gateway_password() { - gw_pass.to_owned() - } else { - inquire::Password::new("Gateway password:") + prompt_missing(builder) + } +} + +/// Resolve the remaining [`MissingField`]s by prompting for credentials/addresses and deriving the +/// frontend-specific client identity, then build the [`Config`]. +fn prompt_missing(mut builder: ConfigBuilder) -> anyhow::Result { + for field in builder.missing() { + builder = match field { + MissingField::ServerAddress => { + let dest = inquire::Text::new("Server address:") + .prompt() + .context("Address prompt")? + .pipe(Destination::new)?; + builder.with_destination(dest) + } + MissingField::Username => { + let username = inquire::Text::new("Username:").prompt().context("Username prompt")?; + builder.with_username(username) + } + MissingField::Password => { + let password = inquire::Password::new("Password:") .without_confirmation() .prompt() - .context("Password prompt")? - }; - }; - - let target = match properties.full_address().context("invalid 'full address' property")? { - Some(addr) => Some(addr), - None => properties - .alternate_full_address() - .context("invalid 'alternate full address' property")?, - }; - - let destination = if let Some(target) = target { - const RDP_DEFAULT_PORT: u16 = 3389; - let port = match target.port { - Some(p) => p, - None => properties - .server_port() - .context("invalid 'server port' property")? - .unwrap_or(RDP_DEFAULT_PORT), - }; - let name = match target.host { - ironrdp_cfg::TargetHost::Ip(ip) => ip.to_string(), - ironrdp_cfg::TargetHost::Domain(host) => host, - }; - Destination::from_parts(name, port) - } else { - inquire::Text::new("Server address:") - .prompt() - .context("Address prompt")? - .pipe(Destination::new)? - }; - - let username = if let Some(username) = properties.username() { - username.to_owned() - } else { - inquire::Text::new("Username:").prompt().context("Username prompt")? - }; - - let password = if let Some(password) = properties.clear_text_password() { - password.to_owned() - } else { - inquire::Password::new("Password:") - .without_confirmation() - .prompt() - .context("Password prompt")? - }; - - let codecs: Vec<_> = self.codecs.iter().map(|s| s.as_str()).collect(); - let codecs = match client_codecs_capabilities(&codecs) { - Ok(codecs) => codecs, - Err(help) => { - print!("{help}"); - std::process::exit(0); + .context("Password prompt")?; + builder.with_password(password) } - }; - let mut bitmap = connector::BitmapConfig { - color_depth: 32, - lossy_compression: true, - codecs, - }; - - if let Some(color_depth) = self.color_depth { - if color_depth != 16 && color_depth != 32 { - anyhow::bail!("Invalid color depth. Only 16 and 32 bit color depths are supported."); + MissingField::GatewayUsername => { + let username = inquire::Text::new("Gateway username:") + .prompt() + .context("Gateway username prompt")?; + builder.with_gateway_username(username) } - bitmap.color_depth = color_depth; - }; - - // make a duration from cmdline argument (minutes) - let fake_events_interval = self - .prevent_session_lock - .map(|v| Duration::from_secs(u64::from(v) * 60)); - - let enable_credssp = properties.enable_credssp_support().unwrap_or(true); - - let redirect_clipboard = properties.redirect_clipboard().unwrap_or(true); - let clipboard_type = resolve_clipboard_type(self.clipboard_type, redirect_clipboard); - - let enable_audio_playback = match properties.audio_mode() { - Ok(None) | Ok(Some(AudioMode::RedirectToClient)) => true, - Ok(Some(AudioMode::PlayOnServer | AudioMode::Disabled)) => false, - Err(e) => { - eprintln!("Warning: {e}, defaulting to audio playback enabled"); - true + MissingField::GatewayPassword => { + let password = inquire::Password::new("Gateway password:") + .without_confirmation() + .prompt() + .context("Gateway password prompt")?; + builder.with_gateway_password(password) } + // Frontend-derived identity: never prompted. + MissingField::ClientBuild => builder.with_client_build(client_build()), + MissingField::ClientDir => { + // NOTE: hardcode this value like in freerdp + // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 + builder.with_client_dir("C:\\Windows\\System32\\mstscax.dll") + } + MissingField::Platform => builder.with_platform(current_platform()), + MissingField::ClientName => builder.with_client_name(client_name()), }; + } - let compression_enabled = properties.compression().unwrap_or(true); - - let compression_type = if compression_enabled { - Some(compression_type_from_level(self.compression_level)?) - } else { - None - }; - - let desktop_width = properties - .desktop_width() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopwidth' property"); - None - }) - .unwrap_or(DEFAULT_WIDTH); - let desktop_height = properties - .desktop_height() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopheight' property"); - None - }) - .unwrap_or(DEFAULT_HEIGHT); - let desktop_scale_factor = properties - .desktop_scale_factor() - .unwrap_or_else(|_| { - eprintln!("Warning: ignored out-of-range 'desktopscalefactor' property"); - None - }) - .unwrap_or(0); - - let kdc_proxy_url = properties - .kdc_proxy_url() - .map(str::to_owned) - .or_else(|| properties.kdc_proxy_name().map(normalize_kdc_proxy_url_from_name)); - - let kerberos_config = kdc_proxy_url.and_then(|kdc_proxy_url| { - Url::parse(&kdc_proxy_url) - .ok() - .map(|url| connector::credssp::KerberosConfig { - kdc_proxy_url: Some(url), - // The hostname field is the client computer name used for Kerberos SPN negotiation. - hostname: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), - }) - .or_else(|| { - eprintln!("Warning: ignored invalid KDC proxy URL in 'kdcproxyname'/'KDCProxyURL' property"); - None - }) - }); - - let connector = connector::Config { - credentials: Credentials::UsernamePassword { username, password }, - domain: properties.domain().map(str::to_owned), - enable_tls: !self.no_tls, - enable_credssp, - keyboard_type: self.keyboard_type.into_pdu(), - keyboard_subtype: self.keyboard_subtype, - keyboard_layout: 0, // the server SHOULD use the default active input locale identifier - keyboard_functional_keys_count: self.keyboard_functional_keys_count, - ime_file_name: self.ime_file_name, - dig_product_id: self.dig_product_id, - desktop_size: connector::DesktopSize { - width: desktop_width, - height: desktop_height, - }, - desktop_scale_factor, - bitmap: Some(bitmap), - client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch) - .pipe(u32::try_from) - .context("cargo package version")?, - client_name: whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), - // NOTE: hardcode this value like in freerdp - // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 - client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), - platform: match whoami::platform() { - whoami::Platform::Windows => MajorPlatformType::WINDOWS, - whoami::Platform::Linux => MajorPlatformType::UNIX, - whoami::Platform::Mac => MajorPlatformType::MACINTOSH, - whoami::Platform::Ios => MajorPlatformType::IOS, - whoami::Platform::Android => MajorPlatformType::ANDROID, - _ => MajorPlatformType::UNSPECIFIED, - }, - hardware_id: None, - license_cache: None, - enable_server_pointer: !self.no_server_pointer, - autologon: self.autologon, - enable_audio_playback, - request_data: None, - pointer_software_rendering: false, - multitransport_flags: None, - compression_type, - performance_flags: PerformanceFlags::default(), - timezone_info: TimezoneInfo::default(), - alternate_shell: properties.alternate_shell().unwrap_or_default().to_owned(), - work_dir: properties.shell_working_directory().unwrap_or_default().to_owned(), - }; - - // Determine the transport. RDCleanPath takes precedence over gateway. - let transport = if let Some(rdcp) = self.rdcleanpath { - Transport::RDCleanPath(rdcp) - } else if let Some(gw) = gw_config { - Transport::Gateway(gw) - } else { - Transport::Direct - }; - - let mut builder = ConfigBuilder::new(connector, destination) - .with_transport(transport) - .with_clipboard(clipboard_type); - - if let Some(kerberos_config) = kerberos_config { - builder = builder.with_kerberos_config(kerberos_config); - } - - if let Some(log_file) = self.log_file { - builder = builder.with_log_file(log_file); - } - - if let Some(fake_events_interval) = fake_events_interval { - builder = builder.with_fake_events_interval(fake_events_interval); - } + builder.build() +} - for proxy in self.dvc_pipe_proxies { - builder = builder.with_dvc_pipe_proxy(proxy); - } +fn client_build() -> u32 { + semver::Version::parse(env!("CARGO_PKG_VERSION")) + .map_or(0, |v| v.major * 100 + v.minor * 10 + v.patch) + .try_into() + .unwrap_or(0) +} - #[cfg(windows)] - for plugin in self.dvc_plugins { - builder = builder.with_dvc_plugin(plugin); - } +fn client_name() -> String { + whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()) +} - Ok(builder.build()) +fn current_platform() -> MajorPlatformType { + match whoami::platform() { + whoami::Platform::Windows => MajorPlatformType::WINDOWS, + whoami::Platform::Linux => MajorPlatformType::UNIX, + whoami::Platform::Mac => MajorPlatformType::MACINTOSH, + whoami::Platform::Ios => MajorPlatformType::IOS, + whoami::Platform::Android => MajorPlatformType::ANDROID, + _ => MajorPlatformType::UNSPECIFIED, } } @@ -696,11 +542,3 @@ fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> Resol ClipboardType::Stub => ResolvedClipboardType::Stub, } } - -fn normalize_kdc_proxy_url_from_name(name: &str) -> String { - if name.starts_with("http://") || name.starts_with("https://") { - name.to_owned() - } else { - format!("https://{name}/KdcProxy") - } -} diff --git a/crates/ironrdp-viewer/src/main.rs b/crates/ironrdp-viewer/src/main.rs index f62400be25..9b579486a3 100644 --- a/crates/ironrdp-viewer/src/main.rs +++ b/crates/ironrdp-viewer/src/main.rs @@ -28,21 +28,15 @@ fn main() -> anyhow::Result<()> { let event_loop_proxy = event_loop.create_proxy(); let (output_event_sender, mut output_event_receiver) = mpsc::channel::(64); let initial_window_size = PhysicalSize::new( - u32::from(config.connector.desktop_size.width), - u32::from(config.connector.desktop_size.height), + u32::from(config.connector().desktop_size.width), + u32::from(config.connector().desktop_size.height), ); - let fake_events_interval = config.fake_events_interval; let client = RdpClient::new(config, output_event_sender); let input_event_sender = client.input_sender(); - let mut app = App::new( - &event_loop, - &input_event_sender, - fake_events_interval, - initial_window_size, - ) - .context("unable to initialize App")?; + let mut app = + App::new(&event_loop, &input_event_sender, initial_window_size).context("unable to initialize App")?; let rt = runtime::Builder::new_multi_thread() .enable_all() From 66c9b5d14d0281320a91e96a184bfdf38fe85d4b Mon Sep 17 00:00:00 2001 From: uchouT Date: Mon, 29 Jun 2026 21:05:36 +0800 Subject: [PATCH 296/325] feat(rdpeusb): implement urbdrc client (#1365) Signed-off-by: uchouT --- Cargo.lock | 2 + crates/ironrdp-rdpeusb/Cargo.toml | 1 + crates/ironrdp-rdpeusb/src/client/device.rs | 368 +++++++++ crates/ironrdp-rdpeusb/src/client/mod.rs | 775 ++++++++++++++++++ crates/ironrdp-rdpeusb/src/lib.rs | 40 + crates/ironrdp-rdpeusb/src/pdu/caps.rs | 4 + .../ironrdp-rdpeusb/src/pdu/completion/mod.rs | 7 + crates/ironrdp-rdpeusb/src/pdu/header.rs | 2 +- .../src/pdu/iface_manipulation.rs | 4 + crates/ironrdp-rdpeusb/src/pdu/notify.rs | 3 + crates/ironrdp-rdpeusb/src/pdu/sink.rs | 5 + crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs | 55 +- crates/ironrdp-testsuite-core/Cargo.toml | 1 + crates/ironrdp-testsuite-core/tests/main.rs | 1 + .../tests/rdpeusb/client.rs | 329 ++++++++ .../tests/rdpeusb/device.rs | 108 +++ .../tests/rdpeusb/mod.rs | 35 + 17 files changed, 1721 insertions(+), 19 deletions(-) create mode 100644 crates/ironrdp-rdpeusb/src/client/device.rs create mode 100644 crates/ironrdp-rdpeusb/src/client/mod.rs create mode 100644 crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs create mode 100644 crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs create mode 100644 crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs diff --git a/Cargo.lock b/Cargo.lock index b4858d7b54..5778761172 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2813,6 +2813,7 @@ name = "ironrdp-rdpeusb" version = "0.1.0" dependencies = [ "ironrdp-core", + "ironrdp-dvc", "ironrdp-pdu", "ironrdp-str", ] @@ -2947,6 +2948,7 @@ dependencies = [ "ironrdp-propertyset", "ironrdp-rdcleanpath", "ironrdp-rdpdr", + "ironrdp-rdpeusb", "ironrdp-rdpfile", "ironrdp-rdpsnd", "ironrdp-server", diff --git a/crates/ironrdp-rdpeusb/Cargo.toml b/crates/ironrdp-rdpeusb/Cargo.toml index c344824072..e235fc1852 100644 --- a/crates/ironrdp-rdpeusb/Cargo.toml +++ b/crates/ironrdp-rdpeusb/Cargo.toml @@ -23,6 +23,7 @@ std = [] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public ironrdp-str = { path = "../ironrdp-str", version = "0.1" } [lints] diff --git a/crates/ironrdp-rdpeusb/src/client/device.rs b/crates/ironrdp-rdpeusb/src/client/device.rs new file mode 100644 index 0000000000..15e36bfd6b --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/client/device.rs @@ -0,0 +1,368 @@ +//! Backend-neutral USB device facts and the RDPEUSB-specific ADD_DEVICE conversion. +//! +//! Backends should fill [`DeviceInfo`] with raw USB topology/descriptor data. This module is +//! responsible for turning those facts into Windows PnP-style strings and RDPEUSB wire wrappers. +//! +//! The split is intentional: +//! - RDPEUSB defines the ADD_DEVICE fields and their wire types, but not every generation detail. +//! - Windows PnP/USB defines the usual hardware ID and compatibility ID formats used for driver +//! matching. +//! - Device instance ID and container ID are enumerator policy. They must be stable identifiers +//! with the RDPEUSB-required shape, so this implementation follows FreeRDP's observed strategy. +//! +//! References: +//! - [MS-RDPEUSB ADD_DEVICE] +//! - [MS-RDPEUSB USB_DEVICE_CAPABILITIES] +//! - [Windows device identification strings] +//! - [Standard USB identifiers] +//! - [USB composite device enumeration] +//! - [USB container ID assignment] +//! - [FreeRDP urbdrc_main.c] +//! - [FreeRDP libusb_udevice.c] +//! +//! [MS-RDPEUSB ADD_DEVICE]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a26bcb6d-d45d-48a9-b9bd-22e0107d8393 +//! [MS-RDPEUSB USB_DEVICE_CAPABILITIES]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/98d4650e-b6d8-47e5-b71b-4d320ab542ee +//! [Windows device identification strings]: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/device-identification-strings +//! [Standard USB identifiers]: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/standard-usb-identifiers +//! [USB composite device enumeration]: https://learn.microsoft.com/en-us/windows-hardware/drivers/usbcon/enumeration-of-the-composite-parent-device +//! [USB container ID assignment]: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/how-usb-devices-are-assigned-container-ids +//! [FreeRDP urbdrc_main.c]: https://github.com/FreeRDP/FreeRDP/blob/master/channels/urbdrc/client/urbdrc_main.c +//! [FreeRDP libusb_udevice.c]: https://github.com/FreeRDP/FreeRDP/blob/master/channels/urbdrc/client/libusb/libusb_udevice.c + +use alloc::{format, string::String, vec, vec::Vec}; + +use ironrdp_pdu::{PduResult, pdu_other_err}; +use ironrdp_str::multi_sz::MultiSzString; +use ironrdp_str::prefixed::Cch32String; + +use crate::pdu::header::{InterfaceId, MessageId}; +use crate::pdu::sink::{ + AddDevice, DeviceSpeed, NoAckIsochWriteJitterBufSizeInMs, SupportedUsbVer, UsbBusIfaceVer, UsbDeviceCaps, UsbdiVer, +}; + +const ADD_DEVICE_MESSAGE_ID: MessageId = 0; +const DEFAULT_NO_ACK_ISOCH_WRITE_JITTER_MS: u32 = 0x50; + +const USB_CLASS_PER_INTERFACE: u8 = 0x00; +const USB_CLASS_MISCELLANEOUS: u8 = 0xef; +const USB_SUBCLASS_COMMON: u8 = 0x02; +const USB_PROTOCOL_INTERFACE_ASSOCIATION: u8 = 0x01; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceInfo { + /// Physical/topological location. Used to derive stable Windows PnP instance/container IDs. + pub location: UsbDeviceLocation, + /// Raw fields from the USB device descriptor. + pub descriptor: UsbDeviceDescriptorInfo, + /// Active configuration, if the backend can read it. Used for composite detection and + /// first-interface class codes. + pub active_config: Option, + /// Backend-observed connection speed. RDPEUSB only carries a high-speed boolean. + pub speed: UsbConnectionSpeed, +} + +impl DeviceInfo { + fn is_composite(&self) -> bool { + let descriptor_class = self.descriptor.class_codes; + // Match FreeRDP/libusb composite detection: either a per-interface class device with + // multiple interfaces, or an Interface Association Descriptor style device class. + // + // Refs: [USB composite device enumeration]; [FreeRDP libusb_udevice.c] + // `interface_create()`. + let has_single_config_multiple_interfaces = self.descriptor.num_configurations == 1 + && descriptor_class.class_code == USB_CLASS_PER_INTERFACE + && self + .active_config + .as_ref() + .is_some_and(|config| config.interfaces.len() > 1); + + let has_interface_association_descriptor = descriptor_class.class_code == USB_CLASS_MISCELLANEOUS + && descriptor_class.sub_class_code == USB_SUBCLASS_COMMON + && descriptor_class.protocol_code == USB_PROTOCOL_INTERFACE_ASSOCIATION; + + has_single_config_multiple_interfaces || has_interface_association_descriptor + } + + fn pnp_class_codes(&self) -> UsbClassCodes { + // FreeRDP uses the first active interface class for compatibility IDs after checking + // whether the whole device is composite. + // + // Ref: [FreeRDP libusb_udevice.c] `interface_create()`. + self.active_config + .as_ref() + .and_then(|config| config.interfaces.first()) + .map(|interface| interface.class_codes) + .unwrap_or(self.descriptor.class_codes) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsbDeviceLocation { + pub bus_number: u8, + pub address: u8, + pub port_numbers: Vec, +} + +impl UsbDeviceLocation { + fn path(&self) -> String { + // FreeRDP uses "bus-last_port" as the device path. Keep the full port chain in + // DeviceInfo for backend fidelity, but only the last port participates in ADD_DEVICE IDs. + // + // Ref: [FreeRDP libusb_udevice.c] `udev_get_device_handle()`. + let last_port_or_address = self.port_numbers.last().copied().unwrap_or(self.address); + + format!("{}-{last_port_or_address}", self.bus_number) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsbDeviceDescriptorInfo { + pub vendor_id: u16, + pub product_id: u16, + pub device_version: u16, + pub usb_version: UsbBcdVersion, + pub class_codes: UsbClassCodes, + pub num_configurations: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsbConfigInfo { + pub interfaces: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsbInterfaceInfo { + pub class_codes: UsbClassCodes, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsbClassCodes { + pub class_code: u8, + pub sub_class_code: u8, + pub protocol_code: u8, +} + +impl UsbClassCodes { + pub const PER_INTERFACE: Self = Self { + class_code: 0x00, + sub_class_code: 0x00, + protocol_code: 0x00, + }; +} + +/// Raw `bcdUSB` value from the USB device descriptor. +/// +/// The value is preserved without BCD validation. RDPEUSB supports only +/// USB 1.0, 1.1, and 2.0, so newer values are advertised as USB 2.0. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsbBcdVersion(u16); +impl UsbBcdVersion { + pub const fn from_bcd(value: u16) -> Self { + Self(value) + } + + fn to_supported_usb_version(self) -> SupportedUsbVer { + if self.0 >= 0x0200 { + SupportedUsbVer::Usb20 + } else if self.0 >= 0x0110 { + SupportedUsbVer::Usb11 + } else { + SupportedUsbVer::Usb10 + } + } + + fn is_at_least_usb20(self) -> bool { + self.0 >= 0x0200 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsbConnectionSpeed { + Unknown, + Low, + Full, + High, + Super, + SuperPlus, +} + +/// Convert backend USB facts into the RDPEUSB ADD_DEVICE PDU. +/// +/// `usb_device` is deliberately passed separately: it is the per-device USB interface ID allocated +/// by the DVC processor, not a property of the USB backend device. +/// +/// The output strings are Windows PnP identifiers. They are opaque to this crate once generated; +/// the important part is using the standard USB forms and keeping instance/container values stable. +pub fn add_device_from_info(usb_device: InterfaceId, info: &DeviceInfo) -> PduResult { + let device_version = info.descriptor.device_version; + let location_path = info.location.path(); + + Ok(AddDevice { + msg_id: ADD_DEVICE_MESSAGE_ID, + usb_device, + // Cch32String and MultiSzString are wire-format concerns. Keep DeviceInfo plain and build + // these counted UTF-16 wrappers only at the RDPEUSB boundary. + // + // Ref: [MS-RDPEUSB ADD_DEVICE] field definitions for cchDeviceInstanceId, cchHwIds, + // cchCompatIds, and cchContainerId. + device_instance_id: Cch32String::new(device_instance_id(&location_path)), + hw_ids: Some( + MultiSzString::new(hardware_ids( + info.descriptor.vendor_id, + info.descriptor.product_id, + device_version, + )) + .map_err(|e| pdu_other_err!("generated ADD_DEVICE hardware IDs contain an embedded nul", source: e))?, + ), + compat_ids: Some(MultiSzString::new(compatibility_ids(info)).map_err( + |e| pdu_other_err!("generated ADD_DEVICE compatibility IDs contain an embedded nul", source: e), + )?), + container_id: Cch32String::new(container_id( + info.descriptor.vendor_id, + info.descriptor.product_id, + &location_path, + )), + usb_device_caps: usb_device_caps(info)?, + }) +} + +fn hardware_ids(vendor_id: u16, product_id: u16, device_version: u16) -> Vec { + // Windows PnP hardware IDs, ordered from most specific to less specific. + // + // Refs: [Standard USB identifiers]; [FreeRDP urbdrc_main.c] + // `urdbrc_send_usb_device_add()`. + vec![ + format!("USB\\VID_{vendor_id:04X}&PID_{product_id:04X}&REV_{device_version:04X}"), + format!("USB\\VID_{vendor_id:04X}&PID_{product_id:04X}"), + ] +} + +fn compatibility_ids(info: &DeviceInfo) -> Vec { + if info.is_composite() { + // Composite devices advertise DevClass_00 plus USB\COMPOSITE, matching FreeRDP. + // + // Refs: [USB composite device enumeration]; [FreeRDP urbdrc_main.c] + // `urdbrc_send_usb_device_add()`. + vec![ + String::from("USB\\DevClass_00&SubClass_00&Prot_00"), + String::from("USB\\DevClass_00&SubClass_00"), + String::from("USB\\DevClass_00"), + String::from("USB\\COMPOSITE"), + ] + } else { + let codes = info.pnp_class_codes(); + + // Non-composite devices advertise class/subclass/protocol in decreasing specificity. + // + // Refs: [Standard USB identifiers]; [FreeRDP urbdrc_main.c] + // `urdbrc_send_usb_device_add()`. + vec![ + format!( + "USB\\Class_{:02X}&SubClass_{:02X}&Prot_{:02X}", + codes.class_code, codes.sub_class_code, codes.protocol_code + ), + format!( + "USB\\Class_{:02X}&SubClass_{:02X}", + codes.class_code, codes.sub_class_code + ), + format!("USB\\Class_{:02X}", codes.class_code), + ] + } +} + +fn usb_device_caps(info: &DeviceInfo) -> PduResult { + // These constants mirror FreeRDP's ADD_DEVICE capabilities. The current PDU enum only models + // USB 1.0/1.1/2.0, so USB 3.x backend versions are reported as Usb20 for this field. + // + // Refs: [MS-RDPEUSB USB_DEVICE_CAPABILITIES]; [FreeRDP urbdrc_main.c] + // `urbdrc_send_add_device()`. + Ok(UsbDeviceCaps { + usb_bus_iface_ver: UsbBusIfaceVer::V2, + usbdi_ver: UsbdiVer::V0x600, + supported_usb_ver: info.descriptor.usb_version.to_supported_usb_version(), + device_speed: device_speed(info)?, + no_ack_isoch_write_jitter_buf_size: NoAckIsochWriteJitterBufSizeInMs::try_from( + DEFAULT_NO_ACK_ISOCH_WRITE_JITTER_MS, + ) + .map_err(|_| pdu_other_err!("default isochronous jitter buffer size is invalid"))?, + }) +} + +fn device_speed(info: &DeviceInfo) -> PduResult { + match info.speed { + UsbConnectionSpeed::Low | UsbConnectionSpeed::Full => Ok(DeviceSpeed::FullSpeed), + UsbConnectionSpeed::High | UsbConnectionSpeed::Super | UsbConnectionSpeed::SuperPlus => { + Ok(DeviceSpeed::HighSpeed) + } + UsbConnectionSpeed::Unknown => { + if info.descriptor.usb_version.is_at_least_usb20() { + Ok(DeviceSpeed::HighSpeed) + } else { + Ok(DeviceSpeed::FullSpeed) + } + } + } +} + +fn device_instance_id(location_path: &str) -> String { + // FreeRDP formats a zero-padded 16-byte ASCII seed as a GUID-looking instance ID. + // + // RDPEUSB only requires a null-terminated Unicode string identifying the USB device instance. + // Windows device identification strings are opaque string-comparison keys, so this is an + // enumerator policy choice rather than a USB descriptor field. + // + // Refs: [MS-RDPEUSB ADD_DEVICE] DeviceInstanceId; [Windows device identification strings]; + // [FreeRDP urbdrc_main.c] `func_instance_id_generate()`. + let raw = format!("\\{location_path}"); + + guid_from_bytes(bytes16_from_ascii(raw.as_bytes()), false) +} + +fn container_id(vendor_id: u16, product_id: u16, location_path: &str) -> String { + // Container ID uses VID/PID plus the last 8 bytes of the location path, with braces. + // + // RDPEUSB requires a non-zero GUID string. Windows uses container IDs to group devnodes that + // represent the same physical device; without the full Windows USB/ACPI/container descriptor + // heuristic available on the client side, follow FreeRDP's stable VID/PID/path-derived value. + // + // Refs: [MS-RDPEUSB ADD_DEVICE] ContainerId; [USB container ID assignment]; + // [FreeRDP urbdrc_main.c] `func_container_id_generate()`. + let path_suffix = location_path + .get(location_path.len().saturating_sub(8)..) + .expect("location path is ASCII"); + let raw = format!("{vendor_id:04X}{product_id:04X}{path_suffix}"); + guid_from_bytes(bytes16_from_ascii(raw.as_bytes()), true) +} + +fn bytes16_from_ascii(value: &[u8]) -> [u8; 16] { + let mut bytes = [0; 16]; + let copy_len = value.len().min(bytes.len()); + + bytes[..copy_len].copy_from_slice(&value[..copy_len]); + + bytes +} + +fn guid_from_bytes(bytes: [u8; 16], braces: bool) -> String { + let guid = format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + ); + + if braces { format!("{{{guid}}}") } else { guid } +} diff --git a/crates/ironrdp-rdpeusb/src/client/mod.rs b/crates/ironrdp-rdpeusb/src/client/mod.rs new file mode 100644 index 0000000000..c4f5ca3886 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/client/mod.rs @@ -0,0 +1,775 @@ +use alloc::collections::btree_map::{BTreeMap, Entry}; +use alloc::string::String; +use alloc::vec; +use alloc::{boxed::Box, vec::Vec}; +use ironrdp_core::{Decode as _, ReadCursor, impl_as_any}; +use ironrdp_dvc::{DvcChannelListener, DvcClientProcessor, DvcMessage, DvcProcessor}; +use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; + +use crate::pdu::UrbdrcServerDevicePdu; +use crate::pdu::completion::ts_urb_result::TsUrbResult; +use crate::pdu::completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}; +use crate::pdu::header::{InterfaceId, Mask, MessageId}; +use crate::pdu::iface_manipulation::{InterfaceRelease, QueryInterfaceFailureResponse}; +use crate::pdu::sink::AddVirtualChannel; +use crate::pdu::usb_dev::ts_urb::TsUrbOut; +use crate::pdu::usb_dev::{InternalIoControl, IoControl, QueryDeviceTextRsp, TransferInRequest, TransferOutRequest}; +use crate::pdu::utils::{HResult, RequestId, RequestIdTransferInOut}; +use crate::pdu::{ + UrbdrcServerControlPdu, + caps::{Capability, RimExchangeCapabilityResponse}, + notify::ChannelCreated, +}; +use crate::{CHANNEL_NAME, InvalidDeviceInterfaceId}; + +pub mod device; +pub use device::*; + +const ADD_VIRTUAL_CHANNEL_MSG_ID: u32 = 0; + +pub trait DeviceManagerBackend: Send { + /// Called when the first URBDRC DVC is assigned as the control DVC. + /// + /// This happens from listener.create(channel_id), before the DVC is fully open. + fn control_channel_assigned(&mut self, channel_id: u32); + + /// Called for each later URBDRC DVC create request. + /// + /// The manager should pop the pending device that caused ADD_VIRTUAL_CHANNEL + fn take_device_for_channel(&mut self, channel_id: u32) -> Option>; +} + +pub struct UrbdrcListener { + on_capability_exchanged: Option, + device_man: Box, + iface_man: InterfaceAlloc, +} + +impl UrbdrcListener { + pub fn new(callback: OnCapabilityExchanged, device_man: Box) -> Self { + Self { + on_capability_exchanged: Some(callback), + device_man, + iface_man: InterfaceAlloc::new(), + } + } +} + +struct InterfaceAlloc { + id: u32, +} + +impl InterfaceAlloc { + #[inline] + const fn new() -> Self { + Self { id: 3 } + } + + #[inline] + const fn alloc(&mut self) -> Option { + self.id += 1; + if self.id > 0x3F_FF_FF_FF { + None + } else { + Some(InterfaceId::from_raw(self.id)) + } + } +} + +impl DvcChannelListener for UrbdrcListener { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn create(&mut self, channel_id: u32) -> Option> { + if let Some(callback) = self.on_capability_exchanged.take() { + self.device_man.control_channel_assigned(channel_id); + Some(Box::new(UrbdrcControlClient::new(callback))) + } else { + let udev_iface = self.iface_man.alloc()?; + #[expect(clippy::as_conversions)] + self.device_man.take_device_for_channel(channel_id).map(|backend| { + Box::new(UrbdrcDeviceClient::new(udev_iface, backend).expect("invalid interface id")) + as Box + }) + } + } +} + +/// A client for the URBDRC Control Virtual Channel. +pub struct UrbdrcControlClient { + /// Spec [3.1]: + /// Exchange-completed event: Signifies that the capability exchange is completed, that is, + /// the client has sent a Channel Created message. + /// + /// [3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/511b4cd7-1940-4631-90ac-bf2189ba6735 + on_capability_exchanged: Option, +} + +type OnCapabilityExchanged = Box PduResult> + Send>; + +impl UrbdrcControlClient { + /// Create a new [UrbdrcControlClient] with the given callback. + /// + /// The `callback` will be called when the capability exchange is completed and the channel is + /// ready to redirect new devices. + pub fn new(callback: OnCapabilityExchanged) -> Self { + Self { + on_capability_exchanged: Some(callback), + } + } + + /// Whether the channel is ready for add virtual channel. + pub const fn ready(&self) -> bool { + self.on_capability_exchanged.is_none() + } + + /// Spec [3.3.5.1.1]: + /// + /// The client sends the ADD_VIRTUAL_CHANNEL message to server to request the server to create a + /// new instance of dynamic virtual channel for USB redirection. The client sends this message + /// for every USB device to be redirected. This isolates messages for each USB device in its own + /// instance of a dynamic virtual channel. + /// + /// [3.3.5.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c7b1920a-d632-46d2-b62a-5c7e53570628 + pub fn add_virtual_channel(&self) -> PduResult { + if !self.ready() { + return Err(pdu_other_err!("is not ready for ADD_VIRTUAL_CHANNEL")); + } + Ok(Box::new(AddVirtualChannel { + msg_id: ADD_VIRTUAL_CHANNEL_MSG_ID, + })) + } +} + +impl DvcProcessor for UrbdrcControlClient { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(Vec::new()) + } + + fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = UrbdrcServerControlPdu::decode(&mut ReadCursor::new(payload)).map_err(|e| decode_err!(e))?; + use UrbdrcServerControlPdu::*; + match pdu { + Caps(caps_req_pdu) => Ok(vec![Box::new(RimExchangeCapabilityResponse { + msg_id: caps_req_pdu.msg_id, + capability: Capability::RimCapabilityVersion01, + result: 0, + })]), + ChanCreated(chan_created_pdu) => Ok(vec![Box::new(ChannelCreated { + msg_id: chan_created_pdu.msg_id, + direction: crate::pdu::notify::Direction::ToServer, + })]), + QueryIfaceReq(query_face_pdu) => Ok(vec![Box::new(QueryInterfaceFailureResponse { + iface_id: query_face_pdu.iface_id, + msg_id: query_face_pdu.msg_id, + })]), + IfaceRelease(InterfaceRelease { + iface_id, + msg_id: _msg_id, + }) => { + if iface_id == InterfaceId::NOTIFY_CLIENT.with_mask(Mask::Proxy) + && let Some(callback) = self.on_capability_exchanged.take() + { + // NOTE: MS-RDPEUSB does not normatively define RIMCALL_RELEASE as a + // server-ready-proceed barrier; the semantic comes from observed Windows + // urbdrc-server behavior. Pattern matches FreeRDP urbdrc_main.c since 2012 + // (commit fa4d8fca1be, Atrust contribution). Two sync points: control DVC + // (server -> client ADD_VIRTUAL_CHANNEL); device DVC (server -> client + // ADD_DEVICE). + callback() + } else { + Ok(Vec::new()) + } + } + } + } +} + +impl_as_any!(UrbdrcControlClient); + +impl DvcClientProcessor for UrbdrcControlClient {} + +pub trait UrbdrcDeviceBackend: Send { + /// Get the USB device information. + fn device_info(&mut self, channel_id: u32) -> PduResult; + /// [Processing a Cancel Request Message][3.3.5.3.1]: + /// + /// The client MUST attempt to stop processing the request identified by the RequestId field in + /// the CANCEL_REQUEST message. If the current request has not been completed it MUST be + /// canceled. If the request has been completed, the client MUST ignore this CANCEL_REQUEST + /// message. + /// + /// [3.3.5.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d5315234-d9ba-42dc-bc1b-b421c57a21ae + fn cancel_request(&mut self, request_id: RequestId, channel_id: u32); + /// [Processing a Query Device Text Message][3.3.5.3.5]: + /// + /// After receiving the QUERY_DEVICE_TEXT message, the client forwards the request to the + /// physical device. When the physical device completes the request, the client sends the result + /// of the request to the server via QUERY_DEVICE_TEXT_RSP message and the RequestId field in + /// the message MUST match the RequestId in the QUERY_DEVICE_TEXT message. + /// + /// [3.3.5.3.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/834f56cc-cfed-4649-8952-0b6486638c28 + fn query_device_text(&mut self, channel_id: u32, text_type: u32, locale_id: u32) -> PduResult>; + /// Process an [`IoControl`] request. + /// + /// Returning [`None`] means the request remains pending and no immediate completion is sent. + fn io_control( + &mut self, + channel_id: u32, + request_id: RequestId, + request: IoControl, + ) -> PduResult>; + /// Process an [`InternalIoControl`] request. + /// + /// Returning [`None`] means the request remains pending and no immediate completion is sent. + fn internal_io_control( + &mut self, + channel_id: u32, + request_id: RequestId, + request: InternalIoControl, + ) -> PduResult>; + /// Process a [`TransferInRequest`]. + /// + /// Returning [`None`] means the request remains pending and no immediate completion is sent. + fn transfer_in( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferInRequest, + ) -> PduResult>; + /// Process a [`TransferOutRequest`]. + /// + /// Returning [`None`] means the request remains pending and no immediate completion is sent. + fn transfer_out( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferOutRequest, + ) -> PduResult>; + /// [Processing a Retract Device Message][3.3.5.3.8]: + /// + /// After receiving the RETRACT_DEVICE message, the client SHOULD terminate the dynamic channel + /// and stop redirecting the physical USB device. + /// + /// [3.3.5.3.8]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/77dc8e12-ddd6-4cb8-a3cc-247aacea7d6f + fn retract(&mut self, channel_id: u32) -> PduResult<()>; +} + +#[derive(Debug, Clone)] +pub struct DeviceText { + pub hresult: u32, + pub description: String, +} + +#[derive(Debug, Clone)] +pub struct IoControlResponse { + pub hresult: HResult, + pub information: u32, + pub output_buffer: Vec, +} + +#[derive(Debug, Clone)] +pub struct UrbInResponse { + pub ts_urb_result: TsUrbResult, + pub hresult: HResult, + pub output_buffer: Vec, +} + +#[derive(Debug, Clone)] +pub struct UrbOutResponse { + pub ts_urb_result: TsUrbResult, + pub hresult: HResult, + pub output_buffer_size: u32, +} + +/// A client for the URBDRC Device Virtual Channel. +pub struct UrbdrcDeviceClient { + /// Indicates whether the channel is ready for handling IO request. + ready_for_io: bool, + /// Per-device USB interface ID allocated by the DVC layer. This is intentionally kept out of + /// `DeviceInfo`, which only describes backend USB facts. + udev_iface: InterfaceId, + request_completion: Option, + backend: Box, + pending_io: BTreeMap, +} + +impl UrbdrcDeviceClient { + pub fn new( + udev_iface: InterfaceId, + backend: Box, + ) -> Result>> { + if u32::from(udev_iface) <= u32::from(InterfaceId::NOTIFY_SERVER) { + return Err(InvalidDeviceInterfaceId::new(backend)); + } + Ok(Self { + ready_for_io: false, + udev_iface, + request_completion: None, + backend, + pending_io: BTreeMap::new(), + }) + } + + pub const fn ready_for_io(&self) -> bool { + self.ready_for_io + } + + pub const fn udev_iface(&self) -> InterfaceId { + self.udev_iface + } + + fn completion_iface_and_entry( + &mut self, + request_id: RequestId, + ) -> PduResult<( + InterfaceId, + alloc::collections::btree_map::OccupiedEntry<'_, u32, Pending>, + )> { + let Some(completion_iface) = self.request_completion else { + return Err(pdu_other_err!("request completion uninitialized")); + }; + let Entry::Occupied(entry) = self.pending_io.entry(request_id) else { + return Err(pdu_other_err!("completion mismatch")); + }; + Ok((completion_iface, entry)) + } + + pub fn io_ctl_completion(&mut self, request_id: RequestId, response: IoControlResponse) -> PduResult { + let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; + let (msg_id, max_output_buf_size) = match entry.get() { + Pending::IoCtl { + msg_id, + max_output_buf_size, + } => (*msg_id, *max_output_buf_size), + _ => return Err(pdu_other_err!("completion mismatch")), + }; + + let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), max_output_buf_size)?; + entry.remove(); + + Ok(Box::new(IoControlCompletion { + msg_id, + completion_iface, + hresult: response.hresult, + request_id, + information: response.information, + output_buffer_size, + output_buffer: response.output_buffer, + })) + } + + pub fn internal_io_ctl_completion( + &mut self, + request_id: RequestId, + response: IoControlResponse, + ) -> PduResult { + let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; + let (msg_id, max_output_buf_size) = match entry.get() { + Pending::InternalIoCtl { + msg_id, + max_output_buf_size, + } => (*msg_id, *max_output_buf_size), + _ => return Err(pdu_other_err!("completion mismatch")), + }; + + let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), max_output_buf_size)?; + entry.remove(); + + Ok(Box::new(IoControlCompletion { + msg_id, + completion_iface, + hresult: response.hresult, + request_id, + information: response.information, + output_buffer_size, + output_buffer: response.output_buffer, + })) + } + + pub fn transfer_in_completion(&mut self, request_id: RequestId, response: UrbInResponse) -> PduResult { + let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; + let (msg_id, max_output_buf_size) = match entry.get() { + Pending::TransferIn { + msg_id, + max_output_buf_size, + } => (*msg_id, *max_output_buf_size), + _ => return Err(pdu_other_err!("completion mismatch")), + }; + + let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), max_output_buf_size)?; + entry.remove(); + + #[expect( + clippy::missing_panics_doc, + reason = "panic is unreachable unless the pending transfer-key invariant is broken" + )] + let req_id = RequestIdTransferInOut::try_from(request_id) + .expect("pending TransferIn request id must be a TS_URB request id"); + + if response.output_buffer.is_empty() { + Ok(Box::new(UrbCompletionNoData { + msg_id, + completion_iface, + req_id, + ts_urb_result: response.ts_urb_result, + hresult: response.hresult, + output_buffer_size, + })) + } else { + Ok(Box::new(UrbCompletion { + msg_id, + completion_iface, + req_id, + ts_urb_result: response.ts_urb_result, + hresult: response.hresult, + output_buffer: response.output_buffer, + })) + } + } + + pub fn transfer_out_completion( + &mut self, + request_id: RequestId, + response: UrbOutResponse, + ) -> PduResult { + let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; + let (msg_id, max_output_buf_size) = match entry.get() { + Pending::TransferOut { + msg_id, + max_output_buf_size, + } => (*msg_id, *max_output_buf_size), + _ => return Err(pdu_other_err!("completion mismatch")), + }; + + if response.output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + + entry.remove(); + + #[expect( + clippy::missing_panics_doc, + reason = "panic is unreachable unless the pending transfer-key invariant is broken" + )] + let req_id = RequestIdTransferInOut::try_from(request_id) + .expect("pending TransferOut request id must be a TS_URB request id"); + + Ok(Box::new(UrbCompletionNoData { + msg_id, + completion_iface, + req_id, + ts_urb_result: response.ts_urb_result, + hresult: response.hresult, + output_buffer_size: response.output_buffer_size, + })) + } +} + +fn check_output_buffer_size(output_buffer_size: usize, max_output_buf_size: u32) -> PduResult { + let output_buffer_size = + u32::try_from(output_buffer_size).map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; + if output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + Ok(output_buffer_size) +} + +impl DvcProcessor for UrbdrcDeviceClient { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(Vec::new()) + } + + fn process(&mut self, channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = UrbdrcServerDevicePdu::decode(&mut ReadCursor::new(payload)).map_err(|e| decode_err!(e))?; + + use UrbdrcServerDevicePdu::*; + match pdu { + ChanCreated(chan_created_pdu) => Ok(vec![Box::new(ChannelCreated { + msg_id: chan_created_pdu.msg_id, + direction: crate::pdu::notify::Direction::ToServer, + })]), + QueryIfaceReq(query_face_pdu) => Ok(vec![Box::new(QueryInterfaceFailureResponse { + iface_id: query_face_pdu.iface_id, + msg_id: query_face_pdu.msg_id, + })]), + IfaceRelease(iface_release_pdu) => { + if iface_release_pdu.iface_id == InterfaceId::NOTIFY_CLIENT.with_mask(Mask::Proxy) && !self.ready_for_io + { + // NOTE: MS-RDPEUSB does not normatively define RIMCALL_RELEASE as a + // server-ready-proceed barrier; the semantic comes from observed Windows + // urbdrc-server behavior. Pattern matches FreeRDP urbdrc_main.c since 2012 + // (commit fa4d8fca1be, Atrust contribution). Two sync points: control DVC + // (server -> client ADD_VIRTUAL_CHANNEL); device DVC (server -> client + // ADD_DEVICE). + let device_info = self.backend.device_info(channel_id)?; + let add_device = add_device_from_info(self.udev_iface, &device_info)?; + self.ready_for_io = true; + + Ok(vec![Box::new(add_device)]) + } else { + Ok(Vec::new()) + } + } + // SPEC [3.1.5]: Out-of-sequence packets are packets that do not adhere to the rules in + // sections 3.2.5 and 3.3.5. Malformed and out-of-sequence packets MUST be ignored by + // the server and the client. + // + // [3.1.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/f31cc9ef-a8c3-4a4d-b64d-f027ed0752b0 + CancelReq(cancel_req_pdu) => { + if !self.ready_for_io || cancel_req_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + if self.pending_io.remove(&cancel_req_pdu.req_id).is_some() { + self.backend.cancel_request(cancel_req_pdu.req_id, channel_id); + } + Ok(Vec::new()) + } + RegReqCb(register_request_callback_pdu) => { + if !self.ready_for_io || register_request_callback_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + self.request_completion = register_request_callback_pdu.request_completion; + Ok(Vec::new()) + } + Retract(retract_pdu) => { + if !self.ready_for_io || retract_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + self.backend.retract(channel_id)?; + self.ready_for_io = false; + self.request_completion = None; + self.pending_io.clear(); + Ok(Vec::new()) + } + DevText(dev_text_pdu) => { + if !self.ready_for_io || dev_text_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + if let Some(device_text) = + self.backend + .query_device_text(channel_id, dev_text_pdu.text_type, dev_text_pdu.locale_id)? + { + Ok(vec![Box::new(QueryDeviceTextRsp { + msg_id: dev_text_pdu.msg_id, + udev_iface: dev_text_pdu.udev_iface, + hresult: device_text.hresult, + device_description: device_text.description.into(), + })]) + } else { + Ok(Vec::new()) + } + } + IoCtl(io_ctl_pdu) => { + if !self.ready_for_io || io_ctl_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + let msg_id = io_ctl_pdu.msg_id; + let request_id = io_ctl_pdu.req_id; + let max_output_buf_size = io_ctl_pdu.output_buffer_size; + if self.pending_io.contains_key(&request_id) { + return Ok(Vec::new()); + } + let Some(completion_iface) = self.request_completion else { + return Ok(Vec::new()); + }; + if let Some(io_ctl_response) = self.backend.io_control(channel_id, request_id, io_ctl_pdu)? { + let output_buffer_size = + check_output_buffer_size(io_ctl_response.output_buffer.len(), max_output_buf_size)?; + Ok(vec![Box::new(IoControlCompletion { + msg_id, + completion_iface, + hresult: io_ctl_response.hresult, + request_id, + information: io_ctl_response.information, + output_buffer_size, + output_buffer: io_ctl_response.output_buffer, + })]) + } else { + self.pending_io.insert( + request_id, + Pending::IoCtl { + msg_id, + max_output_buf_size, + }, + ); + Ok(Vec::new()) + } + } + InternalIoCtl(internal_io_ctl_pdu) => { + if !self.ready_for_io || internal_io_ctl_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + let msg_id = internal_io_ctl_pdu.msg_id; + let request_id = internal_io_ctl_pdu.req_id; + let max_output_buf_size = internal_io_ctl_pdu.output_buffer_size; + if self.pending_io.contains_key(&request_id) { + return Ok(Vec::new()); + } + let Some(completion_iface) = self.request_completion else { + return Ok(Vec::new()); + }; + if let Some(internal_io_ctl_response) = + self.backend + .internal_io_control(channel_id, request_id, internal_io_ctl_pdu)? + { + let output_buffer_size = + check_output_buffer_size(internal_io_ctl_response.output_buffer.len(), max_output_buf_size)?; + Ok(vec![Box::new(IoControlCompletion { + msg_id, + completion_iface, + hresult: internal_io_ctl_response.hresult, + request_id, + information: internal_io_ctl_response.information, + output_buffer_size, + output_buffer: internal_io_ctl_response.output_buffer, + })]) + } else { + self.pending_io.insert( + request_id, + Pending::InternalIoCtl { + msg_id, + max_output_buf_size, + }, + ); + Ok(Vec::new()) + } + } + TransferIn(transfer_in_pdu) => { + if !self.ready_for_io || transfer_in_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + let msg_id = transfer_in_pdu.msg_id; + let max_output_buf_size = transfer_in_pdu.output_buffer_size; + let request_id = transfer_in_pdu.request_id(); + if self.pending_io.contains_key(&request_id.into()) { + return Ok(Vec::new()); + } + let Some(completion_iface) = self.request_completion else { + return Ok(Vec::new()); + }; + if let Some(urb_response) = self + .backend + .transfer_in(channel_id, request_id.into(), transfer_in_pdu)? + { + let output_buffer_size = + check_output_buffer_size(urb_response.output_buffer.len(), max_output_buf_size)?; + if urb_response.output_buffer.is_empty() { + Ok(vec![Box::new(UrbCompletionNoData { + msg_id, + completion_iface, + req_id: request_id, + ts_urb_result: urb_response.ts_urb_result, + hresult: urb_response.hresult, + output_buffer_size, + })]) + } else { + Ok(vec![Box::new(UrbCompletion { + msg_id, + completion_iface, + req_id: request_id, + ts_urb_result: urb_response.ts_urb_result, + hresult: urb_response.hresult, + output_buffer: urb_response.output_buffer, + })]) + } + } else { + self.pending_io.insert( + request_id.into(), + Pending::TransferIn { + msg_id, + max_output_buf_size, + }, + ); + Ok(Vec::new()) + } + } + TransferOut(transfer_out_pdu) => { + if !self.ready_for_io || transfer_out_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + let msg_id = transfer_out_pdu.msg_id; + let output_buffer_size = u32::try_from(transfer_out_pdu.output_buffer.len()) + .map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; + let (request_id, no_ack) = match &transfer_out_pdu.ts_urb { + TsUrbOut::CtlTransfer(urb) => (urb.header.req_id, urb.header.no_ack), + TsUrbOut::BulkInterruptTransfer(urb) => (urb.header.req_id, urb.header.no_ack), + TsUrbOut::IsochTransfer(urb) => (urb.header.req_id, urb.header.no_ack), + TsUrbOut::CtlDescReq(urb) => (urb.header.req_id, urb.header.no_ack), + TsUrbOut::VendorClassReq(urb) => (urb.header.req_id, urb.header.no_ack), + TsUrbOut::CtlTransferEx(urb) => (urb.header.req_id, urb.header.no_ack), + }; + if self.pending_io.contains_key(&request_id.into()) { + return Ok(Vec::new()); + } + + if no_ack { + self.backend + .transfer_out(channel_id, request_id.into(), transfer_out_pdu)?; + Ok(Vec::new()) + } else { + let Some(completion_iface) = self.request_completion else { + return Ok(Vec::new()); + }; + if let Some(urb_response) = + self.backend + .transfer_out(channel_id, request_id.into(), transfer_out_pdu)? + { + if urb_response.output_buffer_size > output_buffer_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + Ok(vec![Box::new(UrbCompletionNoData { + msg_id, + completion_iface, + req_id: request_id, + ts_urb_result: urb_response.ts_urb_result, + hresult: urb_response.hresult, + output_buffer_size: urb_response.output_buffer_size, + })]) + } else { + self.pending_io.insert( + request_id.into(), + Pending::TransferOut { + msg_id, + max_output_buf_size: output_buffer_size, + }, + ); + Ok(Vec::new()) + } + } + } + } + } +} + +impl_as_any!(UrbdrcDeviceClient); + +impl DvcClientProcessor for UrbdrcDeviceClient {} + +enum Pending { + IoCtl { + msg_id: MessageId, + max_output_buf_size: u32, + }, + InternalIoCtl { + msg_id: MessageId, + max_output_buf_size: u32, + }, + TransferIn { + msg_id: MessageId, + max_output_buf_size: u32, + }, + TransferOut { + msg_id: MessageId, + max_output_buf_size: u32, + }, +} diff --git a/crates/ironrdp-rdpeusb/src/lib.rs b/crates/ironrdp-rdpeusb/src/lib.rs index d1ce420eab..15f99f73f2 100644 --- a/crates/ironrdp-rdpeusb/src/lib.rs +++ b/crates/ironrdp-rdpeusb/src/lib.rs @@ -3,4 +3,44 @@ extern crate alloc; +pub const CHANNEL_NAME: &str = "URBDRC"; + +pub mod client; pub mod pdu; + +/// Error returned when a per-device USB interface ID conflicts with an RDPEUSB default interface. +/// +/// RDPEUSB reserves interface IDs `0x0..=0x3` for the built-in Capabilities, Device Sink, and +/// Channel Notification interfaces. A USB Device interface advertised in `ADD_DEVICE` must use a +/// dynamically allocated ID outside that range. +/// +/// The inner value is retained so callers can recover ownership and retry with a different ID. +pub struct InvalidDeviceInterfaceId { + inner: T, +} + +impl InvalidDeviceInterfaceId { + pub fn new(inner: T) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> T { + self.inner + } +} + +impl core::fmt::Debug for InvalidDeviceInterfaceId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("InvalidDeviceInterfaceId").finish_non_exhaustive() + } +} + +impl core::error::Error for InvalidDeviceInterfaceId {} + +impl core::fmt::Display for InvalidDeviceInterfaceId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str( + "invalid USB device interface id: conflicts with RDPEUSB default interfaces (expected id >= 0x00000004)", + ) + } +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/caps.rs b/crates/ironrdp-rdpeusb/src/pdu/caps.rs index b61f5d2c61..5cc00a15ec 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/caps.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/caps.rs @@ -7,6 +7,7 @@ use ironrdp_core::{ DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, invalid_field_err, }; +use ironrdp_dvc::DvcEncode; use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; use crate::pdu::utils::HResult; @@ -151,3 +152,6 @@ impl Encode for RimExchangeCapabilityResponse { Self::FIXED_PART_SIZE } } + +impl DvcEncode for RimExchangeCapabilityRequest {} +impl DvcEncode for RimExchangeCapabilityResponse {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs index c9139b1b92..6addf9fcc7 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs @@ -11,6 +11,7 @@ use alloc::vec::Vec; use ironrdp_core::{ Decode as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, other_err, }; +use ironrdp_dvc::DvcEncode; use ironrdp_pdu::utils::strict_sum; use crate::pdu::completion::ts_urb_result::{TsUrbIsochTransferResult, TsUrbResult, TsUrbResultPayload}; @@ -163,6 +164,8 @@ impl Encode for IoControlCompletion { } } +impl DvcEncode for IoControlCompletion {} + /// [\[MS-RDPEUSB\] 2.2.7.2 URB Completion (URB_COMPLETION)][1] packet. /// /// Sent from the client to the server as the final result of a [`TransferInRequest`] that contains @@ -265,6 +268,8 @@ impl Encode for UrbCompletion { } } +impl DvcEncode for UrbCompletion {} + /// [\[MS-RDPEUSB\] 2.2.7.3 URB Completion No Data (URB_COMPLETION_NO_DATA)][1] packet. /// /// Sent from the client to the server as the final result of a [`TransferInRequest`] that contains @@ -343,3 +348,5 @@ impl Encode for UrbCompletionNoData { + size_of::(/* OutputBufferSize */) } } + +impl DvcEncode for UrbCompletionNoData {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/header.rs b/crates/ironrdp-rdpeusb/src/pdu/header.rs index 38174fbb97..72457788f8 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/header.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/header.rs @@ -106,7 +106,7 @@ impl InterfaceId { self.0 | (u32::from(mask) << 30) } - const fn from_raw(value: u32) -> Self { + pub(crate) const fn from_raw(value: u32) -> Self { Self(value & 0x3F_FF_FF_FF) } } diff --git a/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs b/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs index 4624c1241f..19ea06d080 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs @@ -7,6 +7,7 @@ //! [2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/ebe401f0-f22e-4de4-9cd3-2a55e5493500 use ironrdp_core::{Decode, Encode, ensure_fixed_part_size, ensure_size, invalid_field_err}; +use ironrdp_dvc::DvcEncode; use crate::pdu::header::{FunctionId, MessageId, SharedMsgHeader}; @@ -177,3 +178,6 @@ impl Encode for QueryInterfaceFailureResponse { Self::FIXED_PART_SIZE } } + +impl DvcEncode for QueryInterfaceRequest {} +impl DvcEncode for QueryInterfaceFailureResponse {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/notify.rs b/crates/ironrdp-rdpeusb/src/pdu/notify.rs index a01fae3de8..2399dfd799 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/notify.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/notify.rs @@ -12,6 +12,7 @@ use ironrdp_core::{ DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, unsupported_value_err, }; +use ironrdp_dvc::DvcEncode; use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader, unpack}; @@ -113,3 +114,5 @@ impl Encode for ChannelCreated { Self::FIXED_PART_SIZE } } + +impl DvcEncode for ChannelCreated {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/sink.rs b/crates/ironrdp-rdpeusb/src/pdu/sink.rs index e28148deaa..b6cc66d505 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/sink.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/sink.rs @@ -11,6 +11,7 @@ use ironrdp_core::{ Decode, DecodeOwned as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, invalid_field_err, unsupported_value_err, }; +use ironrdp_dvc::DvcEncode; use ironrdp_pdu::utils::strict_sum; use ironrdp_str::multi_sz::MultiSzString; use ironrdp_str::prefixed::Cch32String; @@ -58,6 +59,8 @@ impl Encode for AddVirtualChannel { } } +impl DvcEncode for AddVirtualChannel {} + /// [\[MS-RDPEUSB\] 2.2.4.2 Add Device Message (ADD_DEVICE)][1] packet. /// /// Sent from the client to the server in order to create a redirected USB device on the server. @@ -187,6 +190,8 @@ impl Encode for AddDevice { } } +impl DvcEncode for AddDevice {} + /// [\[MS-RDPEUSB\] 2.2.11 USB_DEVICE_CAPABILITIES][1] packet. /// /// Defines the capabilities of a USB device. diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs index 7fa1ec0567..11f09607fc 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs @@ -11,11 +11,12 @@ use ironrdp_core::{ Decode as _, DecodeOwned as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, unsupported_value_err, }; +use ironrdp_dvc::DvcEncode; use ironrdp_str::prefixed::Cch32String; use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; use crate::pdu::usb_dev::ts_urb::{TsUrbIn, TsUrbOut}; -use crate::pdu::utils::{HResult, RequestId, RequestIdIoctl}; +use crate::pdu::utils::{HResult, RequestId, RequestIdIoctl, RequestIdTransferInOut}; #[cfg(doc)] use crate::pdu::{ completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}, @@ -79,6 +80,8 @@ impl Encode for CancelRequest { } } +impl DvcEncode for CancelRequest {} + /// [\[MS-RDPEUSB\] 2.2.6.2 Register Request Callback Message (REGISTER_REQUEST_CALLBACK)][1] message. /// /// Sent from the server to the client in order to provide an interface ID for Request Completion @@ -152,6 +155,8 @@ impl Encode for RegisterRequestCallback { } } +impl DvcEncode for RegisterRequestCallback {} + /// [\[MS-RDPEUSB\] 2.2.6.3 IO Control Message (IO_CONTROL)][1] message. /// /// Sent from the server to the client to submit an IO control request to the USB device. @@ -490,6 +495,8 @@ impl Encode for InternalIoControl { } } +impl DvcEncode for InternalIoControl {} + /// [\[MS-RDPEUSB\] 2.2.6.5 Query Device Text Message (QUERY_DEVICE_TEXT)][1] message. /// /// Sent from the server to the client in order to query the USB's device text (like description or @@ -556,6 +563,8 @@ impl Encode for QueryDeviceText { } } +impl DvcEncode for QueryDeviceText {} + /// [\[MS-RDPEUSB\] 2.2.6.6 Query Device Text Response Message (QUERY_DEVICE_TEXT_RSP)][1] message. /// /// Sent from the client in response to a [`QueryDeviceText`] message sent by the server. @@ -617,23 +626,7 @@ impl Encode for QueryDeviceTextRsp { } } -// macro_rules! check_output_buffer_size { -// ($ts_urb:expr, $output_buffer_size:expr) => {{ -// }}; -// } - -// #[derive(Debug)] -// pub struct TransferInRequestOutputBufferSizeErr { -// is: u32, -// expected: u32, -// ts_urb: &'static str, -// } -// -// impl core::fmt::Display for TransferInRequestOutputBufferSizeErr { -// fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { -// write!(f, "") -// } -// } +impl DvcEncode for QueryDeviceTextRsp {} /// [\[MS-RDPEUSB\] 2.2.6.7 Transfer In Request (TRANSFER_IN_REQUEST)][1] message. /// @@ -658,6 +651,26 @@ impl TransferInRequest { } } + pub fn request_id(&self) -> RequestIdTransferInOut { + match &self.ts_urb { + TsUrbIn::SelectConfig(urb) => urb.header.req_id, + TsUrbIn::SelectIface(urb) => urb.header.req_id, + TsUrbIn::PipeReq(urb) => urb.header.req_id, + TsUrbIn::GetCurFrameNum(urb) => urb.header.req_id, + TsUrbIn::CtlTransfer(urb) => urb.header.req_id, + TsUrbIn::BulkInterruptTransfer(urb) => urb.header.req_id, + TsUrbIn::IsochTransfer(urb) => urb.header.req_id, + TsUrbIn::CtlDescReq(urb) => urb.header.req_id, + TsUrbIn::CtlFeatReq(urb) => urb.header.req_id, + TsUrbIn::CtlGetStatus(urb) => urb.header.req_id, + TsUrbIn::VendorClassReq(urb) => urb.header.req_id, + TsUrbIn::CtlGetConfig(urb) => urb.header.req_id, + TsUrbIn::CtlGetIface(urb) => urb.header.req_id, + TsUrbIn::OsFeatDescReq(urb) => urb.header.req_id, + TsUrbIn::CtlTransferEx(urb) => urb.header.req_id, + } + } + pub fn check_output_buffer_size(&self) -> Result<(), &'static str> { use TsUrbIn::*; @@ -745,6 +758,8 @@ impl Encode for TransferInRequest { } } +impl DvcEncode for TransferInRequest {} + /// [\[MS-RDPEUSB\] 2.2.6.8 Transfer Out Request (TRANSFER_OUT_REQUEST)][1] message. /// /// Sent from the server to the client in order to submit data to the USB device. @@ -822,6 +837,8 @@ impl Encode for TransferOutRequest { } } +impl DvcEncode for TransferOutRequest {} + /// [\[MS-RDPEUSB\] 2.2.6.9 Retract Device (RETRACT_DEVICE)][1] message. /// /// Sent from the server to the client in order to stop redirecting the USB device. @@ -895,3 +912,5 @@ pub enum UsbRetractReason { /// server's (group) policy. BlockedByPolicy = 0x1, } + +impl DvcEncode for RetractDevice {} diff --git a/crates/ironrdp-testsuite-core/Cargo.toml b/crates/ironrdp-testsuite-core/Cargo.toml index 68cbd45fd8..969c3b7848 100644 --- a/crates/ironrdp-testsuite-core/Cargo.toml +++ b/crates/ironrdp-testsuite-core/Cargo.toml @@ -51,6 +51,7 @@ ironrdp-svc.path = "../ironrdp-svc" ironrdp-input.path = "../ironrdp-input" ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" ironrdp-rdpdr.path = "../ironrdp-rdpdr" +ironrdp-rdpeusb.path = "../ironrdp-rdpeusb" ironrdp-rdpsnd.path = "../ironrdp-rdpsnd" ironrdp-server.path = "../ironrdp-server" ironrdp-session = { path = "../ironrdp-session", features = ["qoi"] } diff --git a/crates/ironrdp-testsuite-core/tests/main.rs b/crates/ironrdp-testsuite-core/tests/main.rs index 0680089ced..551d311f3f 100644 --- a/crates/ironrdp-testsuite-core/tests/main.rs +++ b/crates/ironrdp-testsuite-core/tests/main.rs @@ -26,6 +26,7 @@ mod pdu; mod propertyset; mod rdcleanpath; mod rdpdr; +mod rdpeusb; mod rdpsnd; mod server; mod server_name; diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs new file mode 100644 index 0000000000..5f51e3d99b --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs @@ -0,0 +1,329 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use ironrdp_core::{decode, encode_vec}; +use ironrdp_dvc::{DvcChannelListener as _, DvcMessage, DvcProcessor as _}; +use ironrdp_pdu::PduResult; +use ironrdp_rdpeusb::CHANNEL_NAME; +use ironrdp_rdpeusb::client::{ + DeviceInfo, DeviceManagerBackend, DeviceText, IoControlResponse, UrbInResponse, UrbOutResponse, + UrbdrcControlClient, UrbdrcDeviceBackend, UrbdrcDeviceClient, UrbdrcListener, +}; +use ironrdp_rdpeusb::pdu::caps::{Capability, RimExchangeCapabilityRequest}; +use ironrdp_rdpeusb::pdu::header::InterfaceId; +use ironrdp_rdpeusb::pdu::iface_manipulation::InterfaceRelease; +use ironrdp_rdpeusb::pdu::notify::{ChannelCreated, Direction}; +use ironrdp_rdpeusb::pdu::sink::AddVirtualChannel; +use ironrdp_rdpeusb::pdu::usb_dev::{InternalIoControl, IoControl, TransferInRequest, TransferOutRequest}; +use ironrdp_rdpeusb::pdu::utils::RequestId; +use ironrdp_rdpeusb::pdu::{ + UrbdrcClientControlPdu, UrbdrcClientDevicePdu, UrbdrcServerControlPdu, UrbdrcServerDevicePdu, +}; + +use super::simple_device_info; + +const STREAM_ID_PROXY: u32 = 1; + +fn proxy_iface_id(iface: InterfaceId) -> u32 { + u32::from(iface) | (STREAM_ID_PROXY << 30) +} + +fn encode_pdu(pdu: &T) -> Vec { + encode_vec(pdu).expect("encode should succeed") +} + +fn decode_control_msg(message: &DvcMessage) -> UrbdrcClientControlPdu { + let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); + decode(&encoded).expect("decode should succeed") +} + +fn decode_device_msg(message: &DvcMessage) -> UrbdrcClientDevicePdu { + let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); + decode(&encoded).expect("decode should succeed") +} + +#[derive(Default)] +struct DeviceManagerState { + control_channel: Option, + device_channels: Vec, + pending_devices: VecDeque>, +} + +struct TestDeviceManager { + state: Arc>, +} + +impl TestDeviceManager { + fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl DeviceManagerBackend for TestDeviceManager { + fn control_channel_assigned(&mut self, channel_id: u32) { + let mut state = self + .state + .lock() + .expect("device manager state lock should not be poisoned"); + assert!( + state.control_channel.replace(channel_id).is_none(), + "control channel should only be assigned once" + ); + } + + fn take_device_for_channel(&mut self, channel_id: u32) -> Option> { + let mut state = self + .state + .lock() + .expect("device manager state lock should not be poisoned"); + + state.pending_devices.pop_front().inspect(|_| { + state.device_channels.push(channel_id); + }) + } +} + +struct TestDeviceBackend { + device_info: DeviceInfo, +} + +impl TestDeviceBackend { + fn new(device_info: DeviceInfo) -> Self { + Self { device_info } + } +} + +impl UrbdrcDeviceBackend for TestDeviceBackend { + fn device_info(&mut self, _channel_id: u32) -> PduResult { + Ok(self.device_info.clone()) + } + + fn cancel_request(&mut self, _request_id: RequestId, _channel_id: u32) {} + + fn query_device_text( + &mut self, + _channel_id: u32, + _text_type: u32, + _locale_id: u32, + ) -> PduResult> { + Ok(None) + } + + fn io_control( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: IoControl, + ) -> PduResult> { + Ok(None) + } + + fn internal_io_control( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: InternalIoControl, + ) -> PduResult> { + Ok(None) + } + + fn transfer_in( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: TransferInRequest, + ) -> PduResult> { + Ok(None) + } + + fn transfer_out( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: TransferOutRequest, + ) -> PduResult> { + Ok(None) + } + + fn retract(&mut self, _channel_id: u32) -> PduResult<()> { + Ok(()) + } +} + +// Ref: [Channel Setup Sequence][1.3.1.1] +// [1.3.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/55bb34fc-7fd0-4aca-8739-5fb6759b66fc +#[test] +fn channel_setup_sequence() { + let manager_state = Arc::new(Mutex::new(DeviceManagerState::default())); + + { + let mut state = manager_state + .lock() + .expect("device manager state lock should not be poisoned"); + state + .pending_devices + .push_back(Box::new(TestDeviceBackend::new(simple_device_info()))); + state + .pending_devices + .push_back(Box::new(TestDeviceBackend::new(simple_device_info()))); + } + + let callback_manager_state = Arc::clone(&manager_state); + + // when channel is settled, send `ADD_VIRTUAL_CHANNEL` + let on_capability_exchanged = Box::new(move || { + let pending_device_count = callback_manager_state + .lock() + .expect("device manager state lock should not be poisoned") + .pending_devices + .len(); + let mut messages = Vec::with_capacity(pending_device_count); + for _ in 0..pending_device_count { + let message: DvcMessage = Box::new(AddVirtualChannel { msg_id: 0 }); + messages.push(message); + } + + Ok(messages) + }); + + let manager = TestDeviceManager::new(Arc::clone(&manager_state)); + let mut listener = UrbdrcListener::new(on_capability_exchanged, Box::new(manager)); + + assert_eq!(listener.channel_name(), CHANNEL_NAME); + + let mut control = listener + .create(10) + .expect("first URBDRC create should return control client"); + + let control = &mut control + .as_any_mut() + .downcast_mut::() + .expect("first processor should be a control client"); + + assert!(!control.ready()); + + let resp = control.start(10).expect("start should succeed"); + assert_eq!(resp.len(), 0); + + let resp = control + .process( + 10, + &encode_pdu(&UrbdrcServerControlPdu::Caps(RimExchangeCapabilityRequest { + msg_id: 7, + capability: Capability::RimCapabilityVersion01, + })), + ) + .expect("capability exchange should succeed"); + + assert_eq!(resp.len(), 1); + let UrbdrcClientControlPdu::Caps(response) = decode_control_msg(&resp[0]) else { + panic!("expected capability response"); + }; + assert_eq!(response.msg_id, 7); + assert_eq!(response.capability, Capability::RimCapabilityVersion01); + assert_eq!(response.result, 0); + + let resp = control + .process( + 10, + &encode_pdu(&UrbdrcServerControlPdu::ChanCreated(ChannelCreated { + msg_id: 8, + direction: Direction::ToClient, + })), + ) + .expect("channel-created notification should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcClientControlPdu::ChanCreated(response) = decode_control_msg(&resp[0]) else { + panic!("expected channel-created response"); + }; + assert_eq!(response.msg_id, 8); + assert_eq!(response.direction, Direction::ToServer); + + let resp = control + .process( + 10, + &encode_pdu(&UrbdrcServerControlPdu::IfaceRelease(InterfaceRelease { + iface_id: proxy_iface_id(InterfaceId::NOTIFY_CLIENT), + msg_id: 9, + })), + ) + .expect("notification release should succeed"); + + // on capability exchanged message + assert_eq!(resp.len(), 2); + assert!(control.ready()); + + for message in &resp { + assert!(matches!( + decode_control_msg(message), + UrbdrcClientControlPdu::AddChan(_) + )); + } + + let device = listener + .create(11) + .expect("second URBDRC create should return device client"); + assert!(device.as_any().downcast_ref::().is_some()); + + let device = listener + .create(12) + .expect("third URBDRC create should return device client"); + assert!(device.as_any().downcast_ref::().is_some()); + + assert!( + listener.create(13).is_none(), + "listener should reject extra URBDRC creates when no device backend is pending" + ); + + let state = manager_state + .lock() + .expect("device manager state lock should not be poisoned"); + assert_eq!(state.control_channel, Some(10)); + assert_eq!(state.device_channels, [11, 12]); + assert!(state.pending_devices.is_empty()); +} + +// Ref: [New Device Sequence][1.3.1.2] +// [1.3.1.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/7e3da218-9cdc-4ebd-bb76-e70202c7f264 +#[test] +fn new_device_sequence() { + let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); + let backend = Box::new(TestDeviceBackend::new(simple_device_info())); + let mut client = UrbdrcDeviceClient::new(udev_iface, backend).expect("device client should be created"); + + assert!(!client.ready_for_io()); + + let resp = client + .process( + 99, + &encode_pdu(&UrbdrcServerDevicePdu::ChanCreated(ChannelCreated { + msg_id: 21, + direction: Direction::ToClient, + })), + ) + .expect("channel-created notification should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcClientDevicePdu::ChanCreated(response) = decode_device_msg(&resp[0]) else { + panic!("expected channel-created response"); + }; + assert_eq!(response.msg_id, 21); + assert_eq!(response.direction, Direction::ToServer); + assert!(!client.ready_for_io()); + + let resp = client + .process( + 99, + &encode_pdu(&UrbdrcServerDevicePdu::IfaceRelease(InterfaceRelease { + iface_id: proxy_iface_id(InterfaceId::NOTIFY_CLIENT), + msg_id: 22, + })), + ) + .expect("notification release should succeed"); + assert_eq!(resp.len(), 1); + assert!(client.ready_for_io()); + + let UrbdrcClientDevicePdu::AddDev(add_device) = decode_device_msg(&resp[0]) else { + panic!("expected add device"); + }; + assert_eq!(add_device.usb_device, udev_iface); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs new file mode 100644 index 0000000000..b1cb5c2392 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs @@ -0,0 +1,108 @@ +use ironrdp_core::encode_vec; +use ironrdp_rdpeusb::client::{ + DeviceInfo, UsbBcdVersion, UsbClassCodes, UsbConfigInfo, UsbConnectionSpeed, UsbDeviceDescriptorInfo, + UsbDeviceLocation, UsbInterfaceInfo, add_device_from_info, +}; +use ironrdp_rdpeusb::pdu::header::InterfaceId; +use rstest::rstest; + +use super::simple_device_info; + +fn composite_device_info() -> DeviceInfo { + DeviceInfo { + active_config: Some(UsbConfigInfo { + interfaces: vec![ + UsbInterfaceInfo { + class_codes: UsbClassCodes { + class_code: 0x03, + sub_class_code: 0x01, + protocol_code: 0x02, + }, + }, + UsbInterfaceInfo { + class_codes: UsbClassCodes { + class_code: 0xff, + sub_class_code: 0x00, + protocol_code: 0x00, + }, + }, + ], + }), + ..simple_device_info() + } +} + +fn iad_composite_device_info() -> DeviceInfo { + let mut info = simple_device_info(); + info.descriptor.class_codes = UsbClassCodes { + class_code: 0xef, + sub_class_code: 0x02, + protocol_code: 0x01, + }; + info +} + +fn no_active_config_device_info() -> DeviceInfo { + DeviceInfo { + active_config: None, + descriptor: UsbDeviceDescriptorInfo { + class_codes: UsbClassCodes { + class_code: 0x08, + sub_class_code: 0x06, + protocol_code: 0x50, + }, + ..simple_device_info().descriptor + }, + ..simple_device_info() + } +} + +fn no_port_numbers_device_info() -> DeviceInfo { + DeviceInfo { + location: UsbDeviceLocation { + bus_number: 7, + address: 2, + port_numbers: Vec::new(), + }, + ..simple_device_info() + } +} + +fn usb_version_device_info(usb_version: UsbBcdVersion) -> DeviceInfo { + DeviceInfo { + descriptor: UsbDeviceDescriptorInfo { + usb_version, + ..simple_device_info().descriptor + }, + ..simple_device_info() + } +} + +fn speed_device_info(speed: UsbConnectionSpeed) -> DeviceInfo { + DeviceInfo { + speed, + ..simple_device_info() + } +} + +#[rstest] +#[case::simple(simple_device_info())] +#[case::composite_multiple_interfaces(composite_device_info())] +#[case::composite_iad(iad_composite_device_info())] +#[case::no_active_config(no_active_config_device_info())] +#[case::no_port_numbers(no_port_numbers_device_info())] +#[case::usb10(usb_version_device_info(UsbBcdVersion::from_bcd(0x0100)))] +#[case::usb11(usb_version_device_info(UsbBcdVersion::from_bcd(0x0110)))] +#[case::usb20(usb_version_device_info(UsbBcdVersion::from_bcd(0x0200)))] +#[case::low_speed(speed_device_info(UsbConnectionSpeed::Low))] +#[case::full_speed(speed_device_info(UsbConnectionSpeed::Full))] +#[case::high_speed(speed_device_info(UsbConnectionSpeed::High))] +#[case::super_speed(speed_device_info(UsbConnectionSpeed::Super))] +#[case::unknown_speed(speed_device_info(UsbConnectionSpeed::Unknown))] +fn add_device_from_protocol_agnostic_device_info(#[case] info: DeviceInfo) { + let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); + let add_device = add_device_from_info(udev_iface, &info).expect("ADD_DEVICE should be generated"); + + assert_eq!(add_device.usb_device, udev_iface); + encode_vec(&add_device).expect("ADD_DEVICE should encode"); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs new file mode 100644 index 0000000000..2fceceb098 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs @@ -0,0 +1,35 @@ +use ironrdp_rdpeusb::client::{ + DeviceInfo, UsbBcdVersion, UsbClassCodes, UsbConfigInfo, UsbConnectionSpeed, UsbDeviceDescriptorInfo, + UsbDeviceLocation, UsbInterfaceInfo, +}; + +fn simple_device_info() -> DeviceInfo { + DeviceInfo { + location: UsbDeviceLocation { + bus_number: 7, + address: 2, + port_numbers: vec![1, 4], + }, + descriptor: UsbDeviceDescriptorInfo { + vendor_id: 0x1234, + product_id: 0xabcd, + device_version: 0x0210, + usb_version: UsbBcdVersion::from_bcd(0x0200), + class_codes: UsbClassCodes::PER_INTERFACE, + num_configurations: 1, + }, + active_config: Some(UsbConfigInfo { + interfaces: vec![UsbInterfaceInfo { + class_codes: UsbClassCodes { + class_code: 0x03, + sub_class_code: 0x01, + protocol_code: 0x02, + }, + }], + }), + speed: UsbConnectionSpeed::Unknown, + } +} + +mod client; +mod device; From b6325f9ea6900a84643b4415f9ebc7b1010cf3cd Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 29 Jun 2026 08:08:45 -0500 Subject: [PATCH 297/325] feat(cliprdr): dispatch initiate_file_copy via ClipboardMessage (#1388) Extends the CLIPRDR backend-facing API to properly support offering clipboard file lists (so later FileContentsRequests can be serviced) by introducing ClipboardMessage::SendInitiateFileCopy(Vec) and wiring it through the in-tree ClipboardMessage dispatchers. --- crates/ironrdp-client/src/rdp.rs | 4 ++++ crates/ironrdp-cliprdr/src/backend.rs | 7 +++++++ crates/ironrdp-server/src/server.rs | 1 + crates/ironrdp-web/src/session.rs | 4 ++++ ffi/src/clipboard/message.rs | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 324bb9dfc9..666df3528f 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -798,6 +798,10 @@ async fn active_session( Some(cliprdr_client.initiate_copy(&formats) .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } + ClipboardMessage::SendInitiateFileCopy(files) => { + Some(cliprdr.initiate_file_copy(files) + .map_err(|e| session::custom_err!("CLIPRDR", e))?) + } ClipboardMessage::SendFormatData(response) => { Some(cliprdr_client.submit_format_data(response) .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) diff --git a/crates/ironrdp-cliprdr/src/backend.rs b/crates/ironrdp-cliprdr/src/backend.rs index 88f4b0eecf..d6f489ed97 100644 --- a/crates/ironrdp-cliprdr/src/backend.rs +++ b/crates/ironrdp-cliprdr/src/backend.rs @@ -43,6 +43,13 @@ pub enum ClipboardMessage { /// Implementation should send file contents response on `CLIPRDR` SVC when received. SendFileContentsResponse(FileContentsResponse<'static>), + /// Sent by clipboard backend when a local file list is ready to be offered to the remote. + /// + /// Implementation should initiate a file copy on `CLIPRDR` SVC when this message is + /// received. Unlike [`ClipboardMessage::SendInitiateCopy`], this records the file list so + /// later `FileContentsRequest`s from the remote can be serviced. + SendInitiateFileCopy(Vec), + /// Failure received from the OS clipboard event loop. /// /// Client implementation should log/display this error. diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 2e57ab1053..e987005201 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1011,6 +1011,7 @@ impl RdpServer { }; let msgs = match c { ClipboardMessage::SendInitiateCopy(formats) => cliprdr.initiate_copy(&formats), + ClipboardMessage::SendInitiateFileCopy(files) => cliprdr.initiate_file_copy(files), ClipboardMessage::SendFormatData(data) => cliprdr.submit_format_data(data), ClipboardMessage::SendInitiatePaste(format) => cliprdr.initiate_paste(format), ClipboardMessage::SendFileContentsRequest(request) => cliprdr.request_file_contents(request), diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index 65fc33441f..02d8adfbd4 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -684,6 +684,10 @@ impl iron_remote_desktop::Session for Session { cliprdr.initiate_copy(&formats) .context("cliprdr initiate copy")? ), + ClipboardMessage::SendInitiateFileCopy(files) => Some( + cliprdr.initiate_file_copy(files) + .context("cliprdr initiate file copy")? + ), ClipboardMessage::SendFormatData(response) => Some( cliprdr.submit_format_data(response) .context("cliprdr submit format data")? diff --git a/ffi/src/clipboard/message.rs b/ffi/src/clipboard/message.rs index 85899bf3c7..129fddd7ef 100644 --- a/ffi/src/clipboard/message.rs +++ b/ffi/src/clipboard/message.rs @@ -13,6 +13,9 @@ pub mod ffi { ironrdp::cliprdr::backend::ClipboardMessage::SendInitiateCopy(_) => { ClipboardMessageType::SendInitiateCopy } + ironrdp::cliprdr::backend::ClipboardMessage::SendInitiateFileCopy(_) => { + ClipboardMessageType::SendInitiateFileCopy + } ironrdp::cliprdr::backend::ClipboardMessage::SendFormatData(_) => ClipboardMessageType::SendFormatData, ironrdp::cliprdr::backend::ClipboardMessage::SendInitiatePaste(_) => { ClipboardMessageType::SendInitiatePaste @@ -86,6 +89,7 @@ pub mod ffi { pub enum ClipboardMessageType { SendInitiateCopy, + SendInitiateFileCopy, SendFormatData, SendInitiatePaste, SendFileContentsRequest, From b407c6fab48a657a828f2c324e0855968f96dd75 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Mon, 29 Jun 2026 19:30:46 -0500 Subject: [PATCH 298/325] fix(client): correct binding and error macro in SendInitiateFileCopy arm (#1390) The `ClipboardMessage::SendInitiateFileCopy` arm in the clipboard event handler refers to a `cliprdr` binding and a `session::custom_err!` macro that are not in scope. The surrounding handler binds the processor as `cliprdr_client`, and every other arm in the same match uses `ironrdp_session::custom_err!`. With the `clipboard` feature enabled this fails to compile: ``` error[E0425]: cannot find value `cliprdr` in this scope error[E0433]: failed to resolve: use of unresolved module or unlinked crate `session` ``` so master does not build with the clipboard feature on. This aligns the arm with its siblings. Verified with `cargo check -p ironrdp-client --features rustls,clipboard`. --- crates/ironrdp-client/src/rdp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 666df3528f..43f001d80e 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -799,8 +799,8 @@ async fn active_session( .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendInitiateFileCopy(files) => { - Some(cliprdr.initiate_file_copy(files) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.initiate_file_copy(files) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendFormatData(response) => { Some(cliprdr_client.submit_format_data(response) From 7df0cf14174d657f5ae06bf7d5fc2560dea3a82b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:04:39 +0900 Subject: [PATCH 299/325] refactor(client): drive viewer config through ConfigBuilder (#1391) Reorganize and complete the PropertySet-backed configuration flow so the .rdp PropertySet is the single source of truth, with the CLI layered on top. ironrdp-cfg: - Reorganize PropertySetExt into co-located getter/setter/clearer triplets, grouped (MS standard keys / IronRDP extensions / multi-key helpers), alphabetized and documented. - Add clear_* methods mirroring every setter so callers can remove keys. ironrdp-client: - Make ConfigBuilder set/clear symmetric: clearing an option (compression, kerberos, sound, destination) now removes the mirrored property instead of leaving a stale value behind. - Introduce addressing-only TransportKind as builder input; resolve it into the bundled Transport at build() time, folding in the separately tracked secrets and erroring on missing gateway/RDCleanPath credentials. - Add MissingField::RDCleanPathToken and with_rdcleanpath_token - Enrich docs on TLS/CredSSP/domain/desktop/compression setters. ironrdp-viewer: - Replace apply_cli_args_to_properties/PartialConfig with ViewerConfig + apply_cli_to_builder (CLI overrides the .rdp-derived builder). - Replace --compression-enabled= with a plain --no-compression flag. - Prompt for RDCleanPath token like gateway credentials; relax the CLI so --rdcleanpath-url no longer requires --rdcleanpath-token. - When dumping the .rdp, dump the effective, secret-stripped PropertySet from the built Config. --- crates/ironrdp-cfg/src/lib.rs | 984 ++++++++++++++---- crates/ironrdp-client/src/config.rs | 392 +++++-- crates/ironrdp-client/src/rdp.rs | 2 +- crates/ironrdp-pdu/src/macros.rs | 2 + .../tests/{config_rdp.rs => client_config.rs} | 2 +- crates/ironrdp-testsuite-extra/tests/e2e.rs | 396 +++++++ crates/ironrdp-testsuite-extra/tests/main.rs | 399 +------ .../ironrdp-viewer/src/{config.rs => cli.rs} | 366 +++---- crates/ironrdp-viewer/src/lib.rs | 2 +- crates/ironrdp-viewer/src/main.rs | 20 +- 10 files changed, 1670 insertions(+), 895 deletions(-) rename crates/ironrdp-testsuite-extra/tests/{config_rdp.rs => client_config.rs} (99%) create mode 100644 crates/ironrdp-testsuite-extra/tests/e2e.rs rename crates/ironrdp-viewer/src/{config.rs => cli.rs} (65%) diff --git a/crates/ironrdp-cfg/src/lib.rs b/crates/ironrdp-cfg/src/lib.rs index d506cc2386..5c8743f4bf 100644 --- a/crates/ironrdp-cfg/src/lib.rs +++ b/crates/ironrdp-cfg/src/lib.rs @@ -1,8 +1,28 @@ mod target_addr; +use std::path::PathBuf; + pub use target_addr::{ParseTargetAddrError, TargetAddr, TargetHost}; use ironrdp_propertyset::PropertySet; +/// Property keys whose values are secrets and must never be surfaced verbatim. +/// +/// Matching is case-insensitive, so a single lowercase entry covers casing variants such as +/// `GatewayPassword`/`gatewaypassword` and `ClearTextPassword`/`cleartextpassword`. +const SECRET_KEYS: &[&str] = &[ + "cleartextpassword", // plaintext RDP account password + "gatewaypassword", // RD gateway password (both casings) + "ironrdp_rdcleanpathtoken", // RDCleanPath authentication token +]; + +/// Returns `true` when `key` names a property whose value is a secret (password or token). +/// +/// Consumers that expose property sets to untrusted readers (logs, IPC responses, dumps) should +/// redact the value of any key for which this returns `true`. The comparison is case-insensitive. +pub fn is_secret_key(key: &str) -> bool { + SECRET_KEYS.iter().any(|secret| key.eq_ignore_ascii_case(secret)) +} + /// Error returned when the `server port` property value is outside the valid port range (1–65535). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InvalidServerPort; @@ -30,36 +50,65 @@ impl core::error::Error for InvalidDesktopSize {} /// Controls whether and how an RD Gateway server is used. /// /// Corresponds to the `gatewayusagemethod` `.rdp` property. -/// See also: -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(i64)] pub enum GatewayUsageMethod { - /// 0: Do not use an RD Gateway server. - Direct, - /// 1: Always use an RD Gateway server. - UseAlways, - /// 2: Use an RD Gateway server, bypass for local addresses. - UseBypassLocal, - /// 3: Use an RD Gateway server, never bypass. - UseNeverBypass, - /// 4: Automatically detect RD Gateway settings (client-side heuristic; no explicit gateway configured). - Automatic, + /// Do not use an RD Gateway server. + /// + /// RDC UI: "Bypass RD Gateway server for local addresses" is cleared. + Direct = 0, + + /// Always use the RD Gateway server. + /// + /// RDC UI: bypass-local is cleared. + UseAlways = 1, + + /// Use an RD Gateway server if a direct connection cannot be made. + /// + /// Windows semantics are "try direct, use gateway if direct fails". + /// + /// IronRDP currently does not implement that two-step fallback, and if + /// an explicit gateway hostname is present, it selects it eagerly as the best + /// available approximation. + /// + /// RDC UI: bypass-local is selected. + #[default] + Detect = 2, + + /// Use the default RD Gateway settings. + UseDefaultSettings = 3, + + /// Do not use an RD Gateway server. + /// + /// RDC UI: bypass-local is selected. + DirectBypassLocal = 4, } impl GatewayUsageMethod { /// Returns `true` when the file explicitly requires routing through a gateway server. + /// + /// This is only true for `gatewayusagemethod:i:1`. + /// `Detect` / value 2 may use a gateway, but does not require one. + /// `UseDefaultSettings` / value 3 delegates the decision to client/default policy. pub fn is_gateway_required(self) -> bool { - matches!(self, Self::UseAlways | Self::UseBypassLocal | Self::UseNeverBypass) + matches!(self, Self::UseAlways) + } + + /// Returns `true` when this mode may result in gateway usage. + /// + /// This includes explicit gateway use, detect/on-demand gateway use, + /// and default settings, because defaults or policy may require a gateway. + pub fn may_use_gateway(self) -> bool { + matches!(self, Self::UseAlways | Self::Detect | Self::UseDefaultSettings) } /// Returns the raw integer value for writing to a `.rdp` property set. + #[expect( + clippy::as_conversions, + reason = "the enum is #[repr(i64)] with explicit discriminants" + )] pub fn as_i64(self) -> i64 { - match self { - Self::Direct => 0, - Self::UseAlways => 1, - Self::UseBypassLocal => 2, - Self::UseNeverBypass => 3, - Self::Automatic => 4, - } + self as i64 } } @@ -70,9 +119,9 @@ impl TryFrom for GatewayUsageMethod { match value { 0 => Ok(Self::Direct), 1 => Ok(Self::UseAlways), - 2 => Ok(Self::UseBypassLocal), - 3 => Ok(Self::UseNeverBypass), - 4 => Ok(Self::Automatic), + 2 => Ok(Self::Detect), + 3 => Ok(Self::UseDefaultSettings), + 4 => Ok(Self::DirectBypassLocal), _ => Err(UnknownGatewayUsageMethod(value)), } } @@ -94,19 +143,20 @@ impl core::error::Error for UnknownGatewayUsageMethod {} /// /// Corresponds to the `gatewaycredentialssource` `.rdp` property. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i64)] pub enum GatewayCredentialsSource { /// 0: Use the same credentials as the RDP server (pass-through / NTLM). - UseServerCredentials, + UseServerCredentials = 0, /// 1: Use the gateway-specific user credentials. - UseUserCredentials, + UseUserCredentials = 1, /// 2: Use credentials stored in a profile. - UseProfile, + UseProfile = 2, /// 3: Prompt the user for gateway credentials. - Prompt, + Prompt = 3, /// 4: Use a smart card. - SmartCard, + SmartCard = 4, /// 5: Use the logged-on user's credentials. - UseLogonCredentials, + UseLogonCredentials = 5, } impl TryFrom for GatewayCredentialsSource { @@ -125,6 +175,17 @@ impl TryFrom for GatewayCredentialsSource { } } +impl GatewayCredentialsSource { + /// Returns the raw integer value for writing to a `.rdp` property set. + #[expect( + clippy::as_conversions, + reason = "the enum is #[repr(i64)] with explicit discriminants" + )] + pub fn as_i64(self) -> i64 { + self as i64 + } +} + /// Error returned when a `gatewaycredentialssource` value is not a recognized variant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UnknownGatewayCredentialsSource(pub i64); @@ -141,13 +202,14 @@ impl core::error::Error for UnknownGatewayCredentialsSource {} /// /// Corresponds to the `audiomode` `.rdp` property. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i64)] pub enum AudioMode { /// 0: Redirect audio to the local (client) machine. - RedirectToClient, + RedirectToClient = 0, /// 1: Play audio on the remote computer. - PlayOnServer, + PlayOnServer = 1, /// 2: Do not play audio. - Disabled, + Disabled = 2, } impl TryFrom for AudioMode { @@ -163,6 +225,17 @@ impl TryFrom for AudioMode { } } +impl AudioMode { + /// Returns the raw integer value for writing to a `.rdp` property set. + #[expect( + clippy::as_conversions, + reason = "the enum is #[repr(i64)] with explicit discriminants" + )] + pub fn as_i64(self) -> i64 { + self as i64 + } +} + /// Error returned when an `audiomode` value is not a recognized variant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UnknownAudioMode(pub i64); @@ -175,159 +248,475 @@ impl core::fmt::Display for UnknownAudioMode { impl core::error::Error for UnknownAudioMode {} -pub trait PropertySetExt { - fn full_address(&self) -> Result, ParseTargetAddrError>; +/// Name-to-pipe mapping for a single DVC proxy channel. +#[derive(Clone, Debug)] +pub struct DvcPipeProxy { + pub channel_name: String, + pub pipe_name: String, +} - fn server_port(&self) -> Result, InvalidServerPort>; +/// Error returned when a DVC pipe proxy spec is missing the `=` delimiter between the channel +/// name and the pipe name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DvcPipeSpecMissingDelimiter; + +impl core::fmt::Display for DvcPipeSpecMissingDelimiter { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("DVC pipe proxy spec is missing the '=' delimiter") + } +} + +impl core::error::Error for DvcPipeSpecMissingDelimiter {} +/// Typed accessors for the RDP properties IronRDP understands. +/// +/// Every property is exposed as a triplet of methods sharing the same underlying key: a getter +/// returning the parsed value (if present and valid), a `set_*` mutator writing it, and a `clear_*` +/// mutator removing it. +/// +/// Methods are grouped into three sections: +/// +/// - **Microsoft standard keys** — keys defined by the `.rdp` file format and Microsoft tooling. +/// - **IronRDP extensions** — IronRDP-specific keys, prefixed with `ironrdp_` to avoid colliding +/// with Microsoft keys. +/// - **Multi-key helpers** — convenience mutators acting on several related keys at once. +/// +/// Within each section, properties are ordered alphabetically by their getter name. +pub trait PropertySetExt { + // ── Microsoft standard keys ─────────────────────────────────────────────── + + /// Alternate target server address (`alternate full address`). fn alternate_full_address(&self) -> Result, ParseTargetAddrError>; + /// Sets the `alternate full address` property. + fn set_alternate_full_address(&mut self, value: &TargetAddr); + /// Removes the `alternate full address` property. + fn clear_alternate_full_address(&mut self); - fn domain(&self) -> Option<&str>; + /// Alternate shell to launch on the server instead of the desktop (`alternate shell`). + fn alternate_shell(&self) -> Option<&str>; + /// Sets the `alternate shell` property. + fn set_alternate_shell(&mut self, value: impl Into); + /// Removes the `alternate shell` property. + fn clear_alternate_shell(&mut self); - fn enable_credssp_support(&self) -> Option; + /// Audio output redirection mode (`audiomode`). + fn audio_mode(&self) -> Result, UnknownAudioMode>; + /// Sets the `audiomode` property. + fn set_audio_mode(&mut self, value: AudioMode); + /// Removes the `audiomode` property. + fn clear_audio_mode(&mut self); + + /// Target RDP server password in clear text (`ClearTextPassword`). + /// + /// This is an MsRdpEx addition and a secret; use for testing only. + fn clear_text_password(&self) -> Option<&str>; + /// Sets the `ClearTextPassword` property. + fn set_clear_text_password(&mut self, value: impl Into); + /// Removes the `ClearTextPassword` property. + fn clear_clear_text_password(&mut self); + /// Whether bulk compression is enabled (`compression`). fn compression(&self) -> Option; + /// Sets the `compression` property. + fn set_compression(&mut self, value: bool); + /// Removes the `compression` property. + fn clear_compression(&mut self); - fn gateway_hostname(&self) -> Option<&str>; + /// Requested desktop height in pixels (`desktopheight`). + fn desktop_height(&self) -> Result, InvalidDesktopSize>; + /// Sets the `desktopheight` property. + fn set_desktop_height(&mut self, value: u16); + /// Removes the `desktopheight` property. + fn clear_desktop_height(&mut self); - fn gateway_usage_method(&self) -> Result, UnknownGatewayUsageMethod>; + /// Requested desktop scale factor as a percentage (`desktopscalefactor`). + fn desktop_scale_factor(&self) -> Result, InvalidDesktopSize>; + /// Sets the `desktopscalefactor` property. + fn set_desktop_scale_factor(&mut self, value: u32); + /// Removes the `desktopscalefactor` property. + fn clear_desktop_scale_factor(&mut self); + + /// Requested desktop width in pixels (`desktopwidth`). + fn desktop_width(&self) -> Result, InvalidDesktopSize>; + /// Sets the `desktopwidth` property. + fn set_desktop_width(&mut self, value: u16); + /// Removes the `desktopwidth` property. + fn clear_desktop_width(&mut self); + + /// Domain of the RDP account credentials (`domain`). + fn domain(&self) -> Option<&str>; + /// Sets the `domain` property. + fn set_domain(&mut self, value: String); + /// Removes the `domain` property. + fn clear_domain(&mut self); + + /// Whether CredSSP/NLA support is enabled (`enablecredsspsupport`). + fn enable_credssp_support(&self) -> Option; + /// Sets the `enablecredsspsupport` property. + fn set_enable_credssp_support(&mut self, enabled: bool); + /// Removes the `enablecredsspsupport` property. + fn clear_enable_credssp_support(&mut self); + /// Target server address (`full address`). + fn full_address(&self) -> Result, ParseTargetAddrError>; + /// Sets the `full address` property. + fn set_full_address(&mut self, value: &TargetAddr); + /// Removes the `full address` property. + fn clear_full_address(&mut self); + + /// RD gateway credentials source (`gatewaycredentialssource`). fn gateway_credentials_source(&self) -> Result, UnknownGatewayCredentialsSource>; + /// Sets the `gatewaycredentialssource` property. + fn set_gateway_credentials_source(&mut self, value: GatewayCredentialsSource); + /// Removes the `gatewaycredentialssource` property. + fn clear_gateway_credentials_source(&mut self); - fn gateway_username(&self) -> Option<&str>; + /// RD gateway endpoint hostname (`gatewayhostname`). + fn gateway_hostname(&self) -> Option<&str>; + /// Sets the `gatewayhostname` property. + fn set_gateway_hostname(&mut self, value: impl Into); + /// Removes the `gatewayhostname` property. + fn clear_gateway_hostname(&mut self); + /// RD gateway password (`GatewayPassword`; secret). + /// + /// Reads either the `GatewayPassword` or `gatewaypassword` casing. fn gateway_password(&self) -> Option<&str>; + /// Sets the `GatewayPassword` property (and removes the `gatewaypassword` casing). + fn set_gateway_password(&mut self, value: impl Into); + /// Removes the `GatewayPassword` property (both casings). + fn clear_gateway_password(&mut self); - fn desktop_width(&self) -> Result, InvalidDesktopSize>; - - fn desktop_height(&self) -> Result, InvalidDesktopSize>; + /// RD gateway usage method (`gatewayusagemethod`). + fn gateway_usage_method(&self) -> Result, UnknownGatewayUsageMethod>; + /// Sets the `gatewayusagemethod` property. + fn set_gateway_usage_method(&mut self, value: GatewayUsageMethod); + /// Removes the `gatewayusagemethod` property. + fn clear_gateway_usage_method(&mut self); - fn desktop_scale_factor(&self) -> Result, InvalidDesktopSize>; + /// RD gateway username (`gatewayusername`). + fn gateway_username(&self) -> Option<&str>; + /// Sets the `gatewayusername` property. + fn set_gateway_username(&mut self, value: impl Into); + /// Removes the `gatewayusername` property. + fn clear_gateway_username(&mut self); - fn alternate_shell(&self) -> Option<&str>; + /// Kerberos KDC proxy name (`kdcproxyname`). + fn kdc_proxy_name(&self) -> Option<&str>; + /// Sets the `kdcproxyname` property. + fn set_kdc_proxy_name(&mut self, value: impl Into); + /// Removes the `kdcproxyname` property. + fn clear_kdc_proxy_name(&mut self); - fn shell_working_directory(&self) -> Option<&str>; + /// Kerberos KDC proxy URL (`kdcproxyurl`). + /// + /// Reads either the `kdcproxyurl` or `KDCProxyURL` casing. + fn kdc_proxy_url(&self) -> Option<&str>; + /// Sets the `kdcproxyurl` property (and removes the `KDCProxyURL` casing). + fn set_kdc_proxy_url(&mut self, value: impl Into); + /// Removes the `kdcproxyurl` property (both casings). + fn clear_kdc_proxy_url(&mut self); + /// Whether clipboard redirection is requested (`redirectclipboard`). fn redirect_clipboard(&self) -> Option; + /// Sets the `redirectclipboard` property. + fn set_redirect_clipboard(&mut self, value: bool); + /// Removes the `redirectclipboard` property. + fn clear_redirect_clipboard(&mut self); - fn audio_mode(&self) -> Result, UnknownAudioMode>; - + /// RemoteApp application name (`remoteapplicationname`). fn remote_application_name(&self) -> Option<&str>; + /// Sets the `remoteapplicationname` property. + fn set_remote_application_name(&mut self, value: impl Into); + /// Removes the `remoteapplicationname` property. + fn clear_remote_application_name(&mut self); + /// RemoteApp executable path or alias (`remoteapplicationprogram`). fn remote_application_program(&self) -> Option<&str>; + /// Sets the `remoteapplicationprogram` property. + fn set_remote_application_program(&mut self, value: impl Into); + /// Removes the `remoteapplicationprogram` property. + fn clear_remote_application_program(&mut self); - fn kdc_proxy_url(&self) -> Option<&str>; + /// Target server port (`server port`). + fn server_port(&self) -> Result, InvalidServerPort>; + /// Sets the `server port` property. + fn set_server_port(&mut self, value: u16); + /// Removes the `server port` property. + fn clear_server_port(&mut self); - fn kdc_proxy_name(&self) -> Option<&str>; + /// Working directory for the alternate shell (`shell working directory`). + fn shell_working_directory(&self) -> Option<&str>; + /// Sets the `shell working directory` property. + fn set_shell_working_directory(&mut self, value: impl Into); + /// Removes the `shell working directory` property. + fn clear_shell_working_directory(&mut self); + /// Username of the RDP account credentials (`username`). fn username(&self) -> Option<&str>; + /// Sets the `username` property. + fn set_username(&mut self, value: impl Into); + /// Removes the `username` property. + fn clear_username(&mut self); - /// Target RDP server password - use for testing only - fn clear_text_password(&self) -> Option<&str>; + // ── IronRDP extensions ──────────────────────────────────────────────────── - /// RDCleanPath proxy URL (IronRDP extension). - fn rdcleanpath_url(&self) -> Option<&str>; + /// Automatically log on by passing the `INFO_AUTOLOGON` flag (`ironrdp_autologon`). + fn autologon(&self) -> Option; + /// Sets the `ironrdp_autologon` property. + fn set_autologon(&mut self, enabled: bool); + /// Removes the `ironrdp_autologon` property. + fn clear_autologon(&mut self); - /// RDCleanPath authentication token (IronRDP extension) - secret, use for testing only. - fn rdcleanpath_token(&self) -> Option<&str>; + /// Color depth in bits per pixel, e.g. 16 or 32 (`ironrdp_colordepth`). + fn color_depth(&self) -> Option; + /// Sets the `ironrdp_colordepth` property. + fn set_color_depth(&mut self, depth: u32); + /// Removes the `ironrdp_colordepth` property. + fn clear_color_depth(&mut self); - /// DVC pipe proxy specifications (IronRDP extension). + /// Bulk compression level: 0=K8, 1=K64, 2=Rdp6, 3=Rdp61 (`ironrdp_compressionlevel`). + fn compression_level(&self) -> Option; + /// Sets the `ironrdp_compressionlevel` property. + fn set_compression_level(&mut self, level: u32); + /// Removes the `ironrdp_compressionlevel` property. + fn clear_compression_level(&mut self); + + /// DVC pipe proxy specifications (`ironrdp_dvcpipeproxy`). /// - /// Comma-separated list of `=` entries. - fn dvc_pipe_proxies(&self) -> Option<&str>; + /// The underlying value is a comma-separated list of `=` entries. + fn dvc_pipe_proxies(&self) -> impl Iterator>; + /// Sets the `ironrdp_dvcpipeproxy` property from an iterator of specifications. + fn set_dvc_pipe_proxies(&mut self, specs: T) + where + T: IntoIterator; + /// Removes the `ironrdp_dvcpipeproxy` property. + fn clear_dvc_pipe_proxies(&mut self); + + /// DVC client plugin DLL paths, comma-separated; Windows only (`ironrdp_dvcplugin`). + fn dvc_plugins(&self) -> impl Iterator; + /// Sets the `ironrdp_dvcplugin` property from an iterator of paths. + fn set_dvc_plugins<'a, T>(&mut self, paths: T) + where + T: IntoIterator; + /// Removes the `ironrdp_dvcplugin` property. + fn clear_dvc_plugins(&mut self); + + /// Enable the QOI bitmap codec (`ironrdp_qoi`). + fn enable_qoi(&self) -> Option; + /// Sets the `ironrdp_qoi` property. + fn set_enable_qoi(&mut self, enabled: bool); + /// Removes the `ironrdp_qoi` property. + fn clear_enable_qoi(&mut self); + + /// Enable the QOIZ bitmap codec (`ironrdp_qoiz`). + fn enable_qoiz(&self) -> Option; + /// Sets the `ironrdp_qoiz` property. + fn set_enable_qoiz(&mut self, enabled: bool); + /// Removes the `ironrdp_qoiz` property. + fn clear_enable_qoiz(&mut self); + + /// Enable RDPDR device redirection (`ironrdp_rdpdr`). + fn enable_rdpdr(&self) -> Option; + /// Sets the `ironrdp_rdpdr` property. + fn set_enable_rdpdr(&mut self, enabled: bool); + /// Removes the `ironrdp_rdpdr` property. + fn clear_enable_rdpdr(&mut self); + + /// Enable smart-card redirection within RDPDR (`ironrdp_smartcard`). + fn enable_smartcard(&self) -> Option; + /// Sets the `ironrdp_smartcard` property. + fn set_enable_smartcard(&mut self, enabled: bool); + /// Removes the `ironrdp_smartcard` property. + fn clear_enable_smartcard(&mut self); + + /// Enable TLS + graphical login; default enabled (`ironrdp_tls`). + fn enable_tls(&self) -> Option; + /// Sets the `ironrdp_tls` property. + fn set_enable_tls(&mut self, enabled: bool); + /// Removes the `ironrdp_tls` property. + fn clear_enable_tls(&mut self); - /// Idle anti-lock fake events interval in minutes (IronRDP extension). + /// Idle anti-lock fake events interval in minutes (`ironrdp_fakeeventsinterval`). fn fake_events_interval(&self) -> Option; + /// Sets the `ironrdp_fakeeventsinterval` property. + fn set_fake_events_interval(&mut self, minutes: u32); + /// Removes the `ironrdp_fakeeventsinterval` property. + fn clear_fake_events_interval(&mut self); + + /// RDCleanPath authentication token; secret (`ironrdp_rdcleanpathtoken`). + fn rdcleanpath_token(&self) -> Option<&str>; + /// Sets the `ironrdp_rdcleanpathtoken` property. + fn set_rdcleanpath_token(&mut self, value: impl Into); + /// Removes the `ironrdp_rdcleanpathtoken` property. + fn clear_rdcleanpath_token(&mut self); + + /// RDCleanPath proxy URL (`ironrdp_rdcleanpathurl`). + fn rdcleanpath_url(&self) -> Option<&str>; + /// Sets the `ironrdp_rdcleanpathurl` property. + fn set_rdcleanpath_url(&mut self, value: impl Into); + /// Removes the `ironrdp_rdcleanpathurl` property. + fn clear_rdcleanpath_url(&mut self); - /// Enable RDPDR device redirection (IronRDP extension). - fn rdpdr_enabled(&self) -> Option; + /// Render the server-side pointer; default enabled (`ironrdp_serverpointer`). + fn server_pointer(&self) -> Option; + /// Sets the `ironrdp_serverpointer` property. + fn set_server_pointer(&mut self, enabled: bool); + /// Removes the `ironrdp_serverpointer` property. + fn clear_server_pointer(&mut self); - /// Enable smart-card redirection within RDPDR (IronRDP extension). - fn smartcard_enabled(&self) -> Option; + // ── Multi-key helpers ───────────────────────────────────────────────────── - /// Enable the QOI bitmap codec (IronRDP extension). - fn qoi_enabled(&self) -> Option; + /// Removes every gateway-related key (`gatewayhostname`, `gatewayusagemethod`, + /// `gatewayusername`, and both `GatewayPassword` casings). + fn clear_gateway(&mut self); - /// Enable the QOIZ bitmap codec (IronRDP extension). - fn qoiz_enabled(&self) -> Option; + /// Removes every RDCleanPath-related key (`ironrdp_rdcleanpathurl` and + /// `ironrdp_rdcleanpathtoken`). + fn clear_rdcleanpath(&mut self); +} - /// Enable TLS + graphical login (IronRDP extension; default enabled). - fn enable_tls(&self) -> Option; +impl PropertySetExt for PropertySet { + // ── Microsoft standard keys ─────────────────────────────────────────────── - /// Render the server-side pointer (IronRDP extension; default enabled). - fn server_pointer(&self) -> Option; + fn alternate_full_address(&self) -> Result, ParseTargetAddrError> { + self.get::<&str>("alternate full address") + .map(|s| s.parse()) + .transpose() + } - /// Automatically log on by passing the INFO_AUTOLOGON flag (IronRDP extension). - fn autologon(&self) -> Option; + fn set_alternate_full_address(&mut self, value: &TargetAddr) { + self.insert("alternate full address", value.to_string()); + } - /// Bulk compression level (IronRDP extension): 0=K8, 1=K64, 2=Rdp6, 3=Rdp61. - fn compression_level(&self) -> Option; + fn clear_alternate_full_address(&mut self) { + self.remove("alternate full address"); + } - /// Color depth in bits per pixel (IronRDP extension), e.g. 16 or 32. - fn color_depth(&self) -> Option; + fn alternate_shell(&self) -> Option<&str> { + self.get::<&str>("alternate shell") + } - /// DVC client plugin DLL paths (IronRDP extension; Windows only). Comma-separated. - fn dvc_plugins(&self) -> Option<&str>; + fn set_alternate_shell(&mut self, value: impl Into) { + self.insert("alternate shell", value.into()); + } - // --- Setters (mirror the getters above; write the same keys) --- + fn clear_alternate_shell(&mut self) { + self.remove("alternate shell"); + } - fn set_enable_credssp_support(&mut self, enabled: bool); - fn set_compression(&mut self, enabled: bool); - fn set_enable_tls(&mut self, enabled: bool); - fn set_server_pointer(&mut self, enabled: bool); - fn set_autologon(&mut self, enabled: bool); - fn set_compression_level(&mut self, level: u32); - fn set_color_depth(&mut self, depth: u32); - fn set_fake_events_interval(&mut self, minutes: u32); - fn set_dvc_plugins(&mut self, value: impl Into); - fn set_dvc_pipe_proxies(&mut self, value: impl Into); - fn set_rdcleanpath_url(&mut self, value: impl Into); - fn set_rdcleanpath_token(&mut self, value: impl Into); - fn clear_rdcleanpath(&mut self); - fn set_gateway_hostname(&mut self, value: impl Into); - fn set_gateway_usage_method(&mut self, method: GatewayUsageMethod); - fn set_gateway_credentials(&mut self, username: impl Into, password: impl Into); - fn clear_gateway(&mut self); - fn set_kdc_proxy_url(&mut self, value: impl Into); -} + fn audio_mode(&self) -> Result, UnknownAudioMode> { + self.get::("audiomode").map(AudioMode::try_from).transpose() + } -impl PropertySetExt for PropertySet { - fn full_address(&self) -> Result, ParseTargetAddrError> { - self.get::<&str>("full address").map(|s| s.parse()).transpose() + fn set_audio_mode(&mut self, value: AudioMode) { + self.insert("audiomode", value.as_i64()); } - fn server_port(&self) -> Result, InvalidServerPort> { - self.get::("server port") - .map(|p| u16::try_from(p).ok().filter(|&p| p != 0).ok_or(InvalidServerPort)) + fn clear_audio_mode(&mut self) { + self.remove("audiomode"); + } + + fn clear_text_password(&self) -> Option<&str> { + self.get::<&str>("ClearTextPassword") + } + + fn set_clear_text_password(&mut self, value: impl Into) { + self.insert("ClearTextPassword", value.into()); + } + + fn clear_clear_text_password(&mut self) { + self.remove("ClearTextPassword"); + } + + fn compression(&self) -> Option { + self.get::("compression") + } + + fn set_compression(&mut self, value: bool) { + self.insert("compression", value); + } + + fn clear_compression(&mut self) { + self.remove("compression"); + } + + fn desktop_height(&self) -> Result, InvalidDesktopSize> { + self.get::("desktopheight") + .map(|v| u16::try_from(v).map_err(|_| InvalidDesktopSize)) .transpose() } - fn alternate_full_address(&self) -> Result, ParseTargetAddrError> { - self.get::<&str>("alternate full address") - .map(|s| s.parse()) + fn set_desktop_height(&mut self, value: u16) { + self.insert("desktopheight", value); + } + + fn clear_desktop_height(&mut self) { + self.remove("desktopheight"); + } + + fn desktop_scale_factor(&self) -> Result, InvalidDesktopSize> { + self.get::("desktopscalefactor") + .map(|v| u32::try_from(v).map_err(|_| InvalidDesktopSize)) .transpose() } + fn set_desktop_scale_factor(&mut self, value: u32) { + self.insert("desktopscalefactor", value); + } + + fn clear_desktop_scale_factor(&mut self) { + self.remove("desktopscalefactor"); + } + + fn desktop_width(&self) -> Result, InvalidDesktopSize> { + self.get::("desktopwidth") + .map(|v| u16::try_from(v).map_err(|_| InvalidDesktopSize)) + .transpose() + } + + fn set_desktop_width(&mut self, value: u16) { + self.insert("desktopwidth", value); + } + + fn clear_desktop_width(&mut self) { + self.remove("desktopwidth"); + } + fn domain(&self) -> Option<&str> { self.get::<&str>("domain") } + fn set_domain(&mut self, value: String) { + self.insert("domain", value); + } + + fn clear_domain(&mut self) { + self.remove("domain"); + } + fn enable_credssp_support(&self) -> Option { self.get::("enablecredsspsupport") } - fn compression(&self) -> Option { - self.get::("compression") + fn set_enable_credssp_support(&mut self, enabled: bool) { + self.insert("enablecredsspsupport", i64::from(enabled)); } - fn gateway_hostname(&self) -> Option<&str> { - self.get::<&str>("gatewayhostname") + fn clear_enable_credssp_support(&mut self) { + self.remove("enablecredsspsupport"); } - fn gateway_usage_method(&self) -> Result, UnknownGatewayUsageMethod> { - self.get::("gatewayusagemethod") - .map(GatewayUsageMethod::try_from) - .transpose() + fn full_address(&self) -> Result, ParseTargetAddrError> { + self.get::<&str>("full address").map(|s| s.parse()).transpose() + } + + fn set_full_address(&mut self, value: &TargetAddr) { + self.insert("full address", value.to_string()); + } + + fn clear_full_address(&mut self) { + self.remove("full address"); } fn gateway_credentials_source(&self) -> Result, UnknownGatewayCredentialsSource> { @@ -336,8 +725,24 @@ impl PropertySetExt for PropertySet { .transpose() } - fn gateway_username(&self) -> Option<&str> { - self.get::<&str>("gatewayusername") + fn set_gateway_credentials_source(&mut self, value: GatewayCredentialsSource) { + self.insert("gatewaycredentialssource", value.as_i64()); + } + + fn clear_gateway_credentials_source(&mut self) { + self.remove("gatewaycredentialssource"); + } + + fn gateway_hostname(&self) -> Option<&str> { + self.get::<&str>("gatewayhostname") + } + + fn set_gateway_hostname(&mut self, value: impl Into) { + self.insert("gatewayhostname", value.into()); + } + + fn clear_gateway_hostname(&mut self) { + self.remove("gatewayhostname"); } fn gateway_password(&self) -> Option<&str> { @@ -345,187 +750,363 @@ impl PropertySetExt for PropertySet { .or_else(|| self.get::<&str>("gatewaypassword")) } - fn desktop_width(&self) -> Result, InvalidDesktopSize> { - self.get::("desktopwidth") - .map(|v| u16::try_from(v).map_err(|_| InvalidDesktopSize)) - .transpose() + fn set_gateway_password(&mut self, value: impl Into) { + self.insert("GatewayPassword", value.into()); + self.remove("gatewaypassword"); } - fn desktop_height(&self) -> Result, InvalidDesktopSize> { - self.get::("desktopheight") - .map(|v| u16::try_from(v).map_err(|_| InvalidDesktopSize)) - .transpose() + fn clear_gateway_password(&mut self) { + self.remove("GatewayPassword"); + self.remove("gatewaypassword"); } - fn desktop_scale_factor(&self) -> Result, InvalidDesktopSize> { - self.get::("desktopscalefactor") - .map(|v| u32::try_from(v).map_err(|_| InvalidDesktopSize)) + fn gateway_usage_method(&self) -> Result, UnknownGatewayUsageMethod> { + self.get::("gatewayusagemethod") + .map(GatewayUsageMethod::try_from) .transpose() } - fn alternate_shell(&self) -> Option<&str> { - self.get::<&str>("alternate shell") + fn set_gateway_usage_method(&mut self, value: GatewayUsageMethod) { + self.insert("gatewayusagemethod", value.as_i64()); } - fn shell_working_directory(&self) -> Option<&str> { - self.get::<&str>("shell working directory") + fn clear_gateway_usage_method(&mut self) { + self.remove("gatewayusagemethod"); + } + + fn gateway_username(&self) -> Option<&str> { + self.get::<&str>("gatewayusername") + } + + fn set_gateway_username(&mut self, value: impl Into) { + self.insert("gatewayusername", value.into()); + } + + fn clear_gateway_username(&mut self) { + self.remove("gatewayusername"); + } + + fn kdc_proxy_name(&self) -> Option<&str> { + self.get::<&str>("kdcproxyname") + } + + fn set_kdc_proxy_name(&mut self, value: impl Into) { + self.insert("kdcproxyname", value.into()); + } + + fn clear_kdc_proxy_name(&mut self) { + self.remove("kdcproxyname"); + } + + fn kdc_proxy_url(&self) -> Option<&str> { + self.get::<&str>("kdcproxyurl") + .or_else(|| self.get::<&str>("KDCProxyURL")) + } + + fn set_kdc_proxy_url(&mut self, value: impl Into) { + self.insert("kdcproxyurl", value.into()); + self.remove("KDCProxyURL"); + } + + fn clear_kdc_proxy_url(&mut self) { + self.remove("kdcproxyurl"); + self.remove("KDCProxyURL"); } fn redirect_clipboard(&self) -> Option { self.get::("redirectclipboard") } - fn audio_mode(&self) -> Result, UnknownAudioMode> { - self.get::("audiomode").map(AudioMode::try_from).transpose() + fn set_redirect_clipboard(&mut self, value: bool) { + self.insert("redirectclipboard", value); + } + + fn clear_redirect_clipboard(&mut self) { + self.remove("redirectclipboard"); } fn remote_application_name(&self) -> Option<&str> { self.get::<&str>("remoteapplicationname") } + fn set_remote_application_name(&mut self, value: impl Into) { + self.insert("remoteapplicationname", value.into()); + } + + fn clear_remote_application_name(&mut self) { + self.remove("remoteapplicationname"); + } + fn remote_application_program(&self) -> Option<&str> { self.get::<&str>("remoteapplicationprogram") } - fn kdc_proxy_url(&self) -> Option<&str> { - self.get::<&str>("kdcproxyurl") - .or_else(|| self.get::<&str>("KDCProxyURL")) + fn set_remote_application_program(&mut self, value: impl Into) { + self.insert("remoteapplicationprogram", value.into()); } - fn kdc_proxy_name(&self) -> Option<&str> { - self.get::<&str>("kdcproxyname") + fn clear_remote_application_program(&mut self) { + self.remove("remoteapplicationprogram"); + } + + fn server_port(&self) -> Result, InvalidServerPort> { + self.get::("server port") + .map(|p| u16::try_from(p).ok().filter(|&p| p != 0).ok_or(InvalidServerPort)) + .transpose() + } + + fn set_server_port(&mut self, value: u16) { + self.insert("server port", value); + } + + fn clear_server_port(&mut self) { + self.remove("server port"); + } + + fn shell_working_directory(&self) -> Option<&str> { + self.get::<&str>("shell working directory") + } + + fn set_shell_working_directory(&mut self, value: impl Into) { + self.insert("shell working directory", value.into()); + } + + fn clear_shell_working_directory(&mut self) { + self.remove("shell working directory"); } fn username(&self) -> Option<&str> { self.get::<&str>("username") } - fn clear_text_password(&self) -> Option<&str> { - self.get::<&str>("ClearTextPassword") + fn set_username(&mut self, value: impl Into) { + self.insert("username", value.into()); } - fn rdcleanpath_url(&self) -> Option<&str> { - self.get::<&str>("ironrdp_rdcleanpathurl") + fn clear_username(&mut self) { + self.remove("username"); } - fn rdcleanpath_token(&self) -> Option<&str> { - self.get::<&str>("ironrdp_rdcleanpathtoken") + // ── IronRDP extensions ──────────────────────────────────────────────────── + + fn autologon(&self) -> Option { + self.get::("ironrdp_autologon") + } + + fn set_autologon(&mut self, enabled: bool) { + self.insert("ironrdp_autologon", enabled); + } + + fn clear_autologon(&mut self) { + self.remove("ironrdp_autologon"); + } + + fn color_depth(&self) -> Option { + self.get::("ironrdp_colordepth") + } + + fn set_color_depth(&mut self, depth: u32) { + self.insert("ironrdp_colordepth", i64::from(depth)); + } + + fn clear_color_depth(&mut self) { + self.remove("ironrdp_colordepth"); + } + + fn compression_level(&self) -> Option { + self.get::("ironrdp_compressionlevel") + } + + fn set_compression_level(&mut self, level: u32) { + self.insert("ironrdp_compressionlevel", i64::from(level)); } - fn dvc_pipe_proxies(&self) -> Option<&str> { + fn clear_compression_level(&mut self) { + self.remove("ironrdp_compressionlevel"); + } + + fn dvc_pipe_proxies(&self) -> impl Iterator> { self.get::<&str>("ironrdp_dvcpipeproxy") + .into_iter() + .flat_map(|value| value.split(',')) + .filter_map(|mut mapping| { + mapping = mapping.trim(); + + if mapping.is_empty() { + return None; + } + + match mapping.split_once('=') { + Some((channel, pipe)) => Some(Ok(DvcPipeProxy { + channel_name: channel.to_owned(), + pipe_name: pipe.to_owned(), + })), + None => Some(Err(DvcPipeSpecMissingDelimiter)), + } + }) + } + + fn set_dvc_pipe_proxies(&mut self, specs: T) + where + T: IntoIterator, + { + let mut value = String::new(); + + for spec in specs { + if !value.is_empty() { + value.push(','); + } + + value.push_str(&spec.channel_name); + value.push('='); + value.push_str(&spec.pipe_name); + } + + self.insert("ironrdp_dvcpipeproxy", value); } - fn fake_events_interval(&self) -> Option { - self.get::("ironrdp_fakeeventsinterval") + fn clear_dvc_pipe_proxies(&mut self) { + self.remove("ironrdp_dvcpipeproxy"); } - fn rdpdr_enabled(&self) -> Option { - self.get::("ironrdp_rdpdr") + fn dvc_plugins(&self) -> impl Iterator { + self.get::<&str>("ironrdp_dvcplugin") + .into_iter() + .flat_map(|value| value.split(',')) + .map(PathBuf::from) } - fn smartcard_enabled(&self) -> Option { - self.get::("ironrdp_smartcard") + fn set_dvc_plugins<'a, T>(&mut self, paths: T) + where + T: IntoIterator, + { + let mut value = String::new(); + + for path in paths.into_iter().flat_map(|path| path.to_str()) { + if !value.is_empty() { + value.push(','); + } + + value.push_str(path); + } + + self.insert("ironrdp_dvcplugin", value); } - fn qoi_enabled(&self) -> Option { + fn clear_dvc_plugins(&mut self) { + self.remove("ironrdp_dvcplugin"); + } + + fn enable_qoi(&self) -> Option { self.get::("ironrdp_qoi") } - fn qoiz_enabled(&self) -> Option { + fn set_enable_qoi(&mut self, enabled: bool) { + self.insert("ironrdp_qoi", enabled); + } + + fn clear_enable_qoi(&mut self) { + self.remove("ironrdp_qoi"); + } + + fn enable_qoiz(&self) -> Option { self.get::("ironrdp_qoiz") } - fn enable_tls(&self) -> Option { - self.get::("ironrdp_tls") + fn set_enable_qoiz(&mut self, enabled: bool) { + self.insert("ironrdp_qoiz", enabled); } - fn server_pointer(&self) -> Option { - self.get::("ironrdp_serverpointer") + fn clear_enable_qoiz(&mut self) { + self.remove("ironrdp_qoiz"); } - fn autologon(&self) -> Option { - self.get::("ironrdp_autologon") + fn enable_rdpdr(&self) -> Option { + self.get::("ironrdp_rdpdr") } - fn compression_level(&self) -> Option { - self.get::("ironrdp_compressionlevel") + fn set_enable_rdpdr(&mut self, enabled: bool) { + self.insert("ironrdp_rdpdr", enabled); } - fn color_depth(&self) -> Option { - self.get::("ironrdp_colordepth") + fn clear_enable_rdpdr(&mut self) { + self.remove("ironrdp_rdpdr"); } - fn dvc_plugins(&self) -> Option<&str> { - self.get::<&str>("ironrdp_dvcplugin") + fn enable_smartcard(&self) -> Option { + self.get::("ironrdp_smartcard") } - fn set_enable_credssp_support(&mut self, enabled: bool) { - self.insert("enablecredsspsupport", i64::from(enabled)); + fn set_enable_smartcard(&mut self, enabled: bool) { + self.insert("ironrdp_smartcard", enabled); } - fn set_compression(&mut self, enabled: bool) { - self.insert("compression", enabled); + fn clear_enable_smartcard(&mut self) { + self.remove("ironrdp_smartcard"); + } + + fn enable_tls(&self) -> Option { + self.get::("ironrdp_tls") } fn set_enable_tls(&mut self, enabled: bool) { self.insert("ironrdp_tls", enabled); } - fn set_server_pointer(&mut self, enabled: bool) { - self.insert("ironrdp_serverpointer", enabled); + fn clear_enable_tls(&mut self) { + self.remove("ironrdp_tls"); } - fn set_autologon(&mut self, enabled: bool) { - self.insert("ironrdp_autologon", enabled); + fn fake_events_interval(&self) -> Option { + self.get::("ironrdp_fakeeventsinterval") } - fn set_compression_level(&mut self, level: u32) { - self.insert("ironrdp_compressionlevel", i64::from(level)); + fn set_fake_events_interval(&mut self, minutes: u32) { + self.insert("ironrdp_fakeeventsinterval", i64::from(minutes)); } - fn set_color_depth(&mut self, depth: u32) { - self.insert("ironrdp_colordepth", i64::from(depth)); + fn clear_fake_events_interval(&mut self) { + self.remove("ironrdp_fakeeventsinterval"); } - fn set_fake_events_interval(&mut self, minutes: u32) { - self.insert("ironrdp_fakeeventsinterval", i64::from(minutes)); + fn rdcleanpath_token(&self) -> Option<&str> { + self.get::<&str>("ironrdp_rdcleanpathtoken") } - fn set_dvc_plugins(&mut self, value: impl Into) { - self.insert("ironrdp_dvcplugin", value.into()); + fn set_rdcleanpath_token(&mut self, value: impl Into) { + self.insert("ironrdp_rdcleanpathtoken", value.into()); } - fn set_dvc_pipe_proxies(&mut self, value: impl Into) { - self.insert("ironrdp_dvcpipeproxy", value.into()); + fn clear_rdcleanpath_token(&mut self) { + self.remove("ironrdp_rdcleanpathtoken"); } - fn set_rdcleanpath_url(&mut self, value: impl Into) { - self.insert("ironrdp_rdcleanpathurl", value.into()); + fn rdcleanpath_url(&self) -> Option<&str> { + self.get::<&str>("ironrdp_rdcleanpathurl") } - fn set_rdcleanpath_token(&mut self, value: impl Into) { - self.insert("ironrdp_rdcleanpathtoken", value.into()); + fn set_rdcleanpath_url(&mut self, value: impl Into) { + self.insert("ironrdp_rdcleanpathurl", value.into()); } - fn clear_rdcleanpath(&mut self) { + fn clear_rdcleanpath_url(&mut self) { self.remove("ironrdp_rdcleanpathurl"); - self.remove("ironrdp_rdcleanpathtoken"); } - fn set_gateway_hostname(&mut self, value: impl Into) { - self.insert("gatewayhostname", value.into()); + fn server_pointer(&self) -> Option { + self.get::("ironrdp_serverpointer") } - fn set_gateway_usage_method(&mut self, method: GatewayUsageMethod) { - self.insert("gatewayusagemethod", method.as_i64()); + fn set_server_pointer(&mut self, enabled: bool) { + self.insert("ironrdp_serverpointer", enabled); } - fn set_gateway_credentials(&mut self, username: impl Into, password: impl Into) { - self.insert("gatewayusername", username.into()); - self.insert("gatewaypassword", password.into()); + fn clear_server_pointer(&mut self) { + self.remove("ironrdp_serverpointer"); } + // ── Multi-key helpers ───────────────────────────────────────────────────── + fn clear_gateway(&mut self) { self.remove("gatewayhostname"); self.remove("gatewayusagemethod"); @@ -534,7 +1115,8 @@ impl PropertySetExt for PropertySet { self.remove("GatewayPassword"); } - fn set_kdc_proxy_url(&mut self, value: impl Into) { - self.insert("kdcproxyurl", value.into()); + fn clear_rdcleanpath(&mut self) { + self.remove("ironrdp_rdcleanpathurl"); + self.remove("ironrdp_rdcleanpathtoken"); } } diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 6b9ef67990..b892b4ef3b 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -77,6 +77,8 @@ pub struct Config { pub(crate) dvc_plugins: Vec, /// The merged PropertySet that produced this config, shared (read-only) with channel factories. + /// + /// Well-known secret properties are stripped when calling [`ConfigBuilder::build`]. pub(crate) properties: PropertySet, pub(crate) extensions: ExtensionRegistry, @@ -174,6 +176,8 @@ pub enum ClipboardType { /// /// Each field is only present when the corresponding Cargo feature is enabled. /// The defaults for all optional fields are `true` (enabled) when the feature is on. +// TODO: Also add flags for all the channels that are not behind Cargo feature flags. +// Examples: ECHO and Display Control virtual channels. #[derive(Clone, Debug)] pub struct ChannelConfig { /// Enable the RDPSND (audio) virtual channel. @@ -247,7 +251,12 @@ impl Default for RdpdrConfig { } } -/// Transport selection for the RDP connection. +/// Fully-resolved transport selection for an established RDP connection. +/// +/// This is the form stored in [`Config`] and consumed by the connection code: every variant +/// carries all the data the transport needs, including any credentials. To *configure* a transport +/// on a [`ConfigBuilder`], use the granular [`TransportKind`] instead — it carries only the +/// addressing, leaving secrets to be supplied (or prompted for) separately. #[derive(Clone, Debug, Default)] pub enum Transport { /// Plain TCP → TLS direct connection to the RDP server. @@ -268,7 +277,47 @@ pub enum Transport { RDCleanPath(RDCleanPathConfig), } -/// Credentials and endpoint for an RDS gateway connection. +/// Transport selection used to configure a [`ConfigBuilder`]. +/// +/// Only the *addressing* of the transport is provided here (the gateway endpoint, the RDCleanPath +/// URL). The associated secrets — gateway username/password and the RDCleanPath authentication +/// token — are supplied through their own dedicated `with_*` methods +/// ([`with_gateway_username`](ConfigBuilder::with_gateway_username), +/// [`with_gateway_password`](ConfigBuilder::with_gateway_password), +/// [`with_rdcleanpath_token`](ConfigBuilder::with_rdcleanpath_token)). +/// +/// Decoupling addressing from secrets means the latter can be tracked as [`MissingField`]s and +/// resolved independently (e.g. prompted interactively) instead of having to be known up-front when +/// the transport is selected. The builder assembles the resolved [`Transport`] from this selection +/// and the collected credentials in [`build`](ConfigBuilder::build). +#[derive(Clone, Debug, Default)] +pub enum TransportKind { + /// Plain TCP → TLS direct connection to the RDP server. + #[default] + Direct, + + /// Connect via an RDS gateway (MS-TSGU / MSTSGU). + /// + /// Gateway credentials are supplied separately via + /// [`with_gateway_username`](ConfigBuilder::with_gateway_username) / + /// [`with_gateway_password`](ConfigBuilder::with_gateway_password). + #[cfg(feature = "gateway")] + Gateway { + /// Gateway endpoint address (e.g., `"rdg.contoso.com:443"`). + endpoint: String, + }, + + /// Connect via an RDCleanPath proxy (WebSocket-based). + /// + /// The authentication token is supplied separately via + /// [`with_rdcleanpath_token`](ConfigBuilder::with_rdcleanpath_token). + RDCleanPath { + /// RDCleanPath proxy URL. + url: Url, + }, +} + +/// Endpoint and credentials for a fully-resolved RDS gateway connection. #[cfg(feature = "gateway")] #[derive(Clone, Debug)] pub struct GatewayConfig { @@ -372,9 +421,12 @@ impl From<&Destination> for ironrdp_connector::ServerName { // ── RDCleanPath & DVC proxy ─────────────────────────────────────────────────── +/// URL and authentication token for a fully-resolved RDCleanPath connection. #[derive(Clone, Debug)] pub struct RDCleanPathConfig { + /// RDCleanPath proxy URL. pub url: Url, + /// RDCleanPath authentication token (secret). pub auth_token: String, } @@ -389,19 +441,12 @@ impl FromStr for DvcProxyInfo { type Err = anyhow::Error; fn from_str(s: &str) -> Result { - let mut parts = s.split('='); - let channel_name = parts - .next() - .ok_or_else(|| anyhow::anyhow!("missing DVC channel name"))? - .to_owned(); - let pipe_name = parts - .next() - .ok_or_else(|| anyhow::anyhow!("missing DVC proxy pipe name"))? - .to_owned(); - + let (channel_name, pipe_name) = s + .split_once('=') + .context("missing '=' delimiter in DVC proxy specification")?; Ok(Self { - channel_name, - pipe_name, + channel_name: channel_name.to_owned(), + pipe_name: pipe_name.to_owned(), }) } } @@ -428,6 +473,8 @@ pub enum MissingField { GatewayUsername, /// Gateway password (only when a gateway transport is selected). GatewayPassword, + /// RDCleanPath authentication token (only when an RDCleanPath transport is selected). + RDCleanPathToken, /// Client build number (frontend-derived). ClientBuild, /// Client directory path (frontend-derived). @@ -446,6 +493,7 @@ impl fmt::Display for MissingField { Self::Password => "password", Self::GatewayUsername => "gateway username", Self::GatewayPassword => "gateway password", + Self::RDCleanPathToken => "RDCleanPath token", Self::ClientBuild => "client build", Self::ClientDir => "client dir", Self::Platform => "platform", @@ -514,7 +562,8 @@ pub struct ConfigBuilder { alternate_shell: Option, work_dir: Option, - transport: Transport, + transport: TransportKind, + rdcleanpath_token: Option, kerberos_config: Option, fake_events_interval: Option, channels: ChannelConfig, @@ -533,20 +582,35 @@ impl ConfigBuilder { #[must_use] pub fn with_destination(mut self, destination: Destination) -> Self { + // Classify the host so the persisted `full address` follows TargetAddr's formatting rules + // (notably, IPv6 addresses must be bracketed). A bare `TargetHost::Domain` would drop the + // brackets and desynchronize the PropertySet from its own canonical formatting. + let host = match destination.name.parse::() { + Ok(ip) => ironrdp_cfg::TargetHost::Ip(ip), + Err(_) => ironrdp_cfg::TargetHost::Domain(destination.name.clone()), + }; + self.properties + .set_full_address(&ironrdp_cfg::TargetAddr { host, port: None }); + self.properties.set_server_port(destination.port); + self.properties.clear_alternate_full_address(); self.destination = Some(destination); self } #[must_use] - pub fn with_credentials(mut self, username: impl Into, password: impl Into) -> Self { - self.username = Some(username.into()); - self.password = Some(password.into()); + pub fn with_username(mut self, username: impl Into) -> Self { + let username = username.into(); + self.username = Some(username.clone()); + self.properties.set_username(username); self } + /// Set the domain used by the RDP account credentials. Upserts the `domain` property. #[must_use] - pub fn with_username(mut self, username: impl Into) -> Self { - self.username = Some(username.into()); + pub fn with_domain(mut self, domain: impl Into) -> Self { + let domain = domain.into(); + self.properties.set_domain(domain.clone()); + self.domain = Some(domain); self } @@ -556,16 +620,11 @@ impl ConfigBuilder { self } - #[must_use] - pub fn with_gateway_credentials(mut self, username: impl Into, password: impl Into) -> Self { - self.gateway_username = Some(username.into()); - self.gateway_password = Some(password.into()); - self - } - #[must_use] pub fn with_gateway_username(mut self, username: impl Into) -> Self { - self.gateway_username = Some(username.into()); + let username = username.into(); + self.gateway_username = Some(username.clone()); + self.properties.set_gateway_username(username); self } @@ -636,6 +695,54 @@ impl ConfigBuilder { self } + /// Set the desktop width (in pixels) to request. Upserts the `desktopwidth` property. + /// + /// Together with [`with_desktop_height`](Self::with_desktop_height) this becomes the initial + /// [`DesktopSize`](ironrdp_connector::DesktopSize) advertised to the server. + #[must_use] + pub fn with_desktop_width(mut self, width: u16) -> Self { + self.desktop_width = Some(width); + self.properties.set_desktop_width(width); + self + } + + /// Set the desktop height (in pixels) to request. Upserts the `desktopheight` property. + /// + /// Together with [`with_desktop_width`](Self::with_desktop_width) this becomes the initial + /// [`DesktopSize`](ironrdp_connector::DesktopSize) advertised to the server. + #[must_use] + pub fn with_desktop_height(mut self, height: u16) -> Self { + self.desktop_height = Some(height); + self.properties.set_desktop_height(height); + self + } + + /// Set the desktop scale factor (percentage, typically 100–500) to request. Upserts the + /// `desktopscalefactor` property. + /// + /// This becomes the `desktop_scale_factor` in the `TS_UD_CS_CORE` GCC structure. + #[must_use] + pub fn with_desktop_scale_factor(mut self, scale: u32) -> Self { + self.desktop_scale_factor = Some(scale); + self.properties.set_desktop_scale_factor(scale); + self + } + + /// Enable or disable TLS + Network Level Authentication (NLA) using CredSSP. Upserts the + /// `enablecredsspsupport` property. + /// + /// NLA allows authentication to be performed before session establishment, considerably + /// reducing the attack surface compared to the legacy TLS security protocol. When connecting to + /// NLA-capable servers it is recommended to also disable plain TLS via + /// [`with_tls(false)`](Self::with_tls). + #[doc(alias("with_nla", "with_enable_credssp"))] + #[must_use] + pub fn with_credssp(mut self, enabled: bool) -> Self { + self.enable_credssp = Some(enabled); + self.properties.set_enable_credssp_support(enabled); + self + } + /// Set the bitmap codecs (e.g. `["remotefx:on"]`). Not reflected in the PropertySet. #[must_use] pub fn with_codecs(mut self, codecs: Vec) -> Self { @@ -650,8 +757,17 @@ impl ConfigBuilder { self } + /// Enable or disable TLS + Graphical login (legacy security protocol; also called SSL). Upserts + /// the `ironrdp_tls` property. + /// + /// When this security protocol is negotiated, the RDP server shows a graphical login screen and + /// the full connection sequence is performed with all static channels joined and active. This + /// exposes a wide attack surface (MITM, server-side and client-side takeover, file stealing) and + /// is being phased out. Set this to `false` to effectively enforce usage of NLA/CredSSP on the + /// client side (see [`with_credssp`](Self::with_credssp)). + #[doc(alias("with_enable_tls"))] #[must_use] - pub fn with_enable_tls(mut self, enabled: bool) -> Self { + pub fn with_tls(mut self, enabled: bool) -> Self { self.enable_tls = Some(enabled); self.properties.set_enable_tls(enabled); self @@ -664,52 +780,100 @@ impl ConfigBuilder { self } - /// Set the bulk compression type directly. Upserts the `ironrdp_compressionlevel` property. + /// Enable or disable bulk compression support. Upserts the `compression` property. + #[must_use] + pub fn with_compression(mut self, enabled: bool) -> Self { + self.compression_enabled = Some(enabled); + self.properties.set_compression(enabled); + self + } + + /// Set the bulk compression type directly. Upserts the `ironrdp_compressionlevel` property, + /// or clears it when `ty` is `None`. + /// + /// When set, the `INFO_COMPRESSION` flag is included in the Client Info PDU and the specified + /// compression type is advertised. The server may then send compressed PDUs using any + /// compression algorithm up to and including this level: + /// + /// - `None` — no compression (default) + /// - `Some(K8)` — MPPC with 8 KB history (RDP 4.0) + /// - `Some(K64)` — MPPC with 64 KB history (RDP 5.0) + /// - `Some(Rdp6)` — NCRUSH (RDP 6.0) + /// - `Some(Rdp61)` — XCRUSH (RDP 6.1) #[must_use] pub fn with_compression_type(mut self, ty: Option) -> Self { self.compression_type = ty; if let Some(ty) = ty { self.properties.set_compression_level(level_from_compression_type(ty)); + } else { + self.properties.clear_compression_level(); } self } - /// Set the transport. Upserts the corresponding properties (`ironrdp_rdcleanpathurl`/token, - /// `gatewayhostname`/usage/credentials), clearing the others so the PropertySet stays consistent. + /// Set the bulk compression type from a level (0–3). Out-of-range levels are ignored. + /// + /// The level maps to a [`CompressionType`](ironrdp_pdu::rdp::client_info::CompressionType): + /// `0` → `K8`, `1` → `K64`, `2` → `Rdp6`, `3` → `Rdp61`. See + /// [`with_compression_type`](Self::with_compression_type) for the semantics of each level. #[must_use] - pub fn with_transport(mut self, transport: Transport) -> Self { + pub fn with_compression_level(self, level: u32) -> Self { + match compression_type_from_level(level) { + Ok(ty) => self.with_compression_type(Some(ty)), + Err(_) => self, + } + } + + /// Select the transport. Upserts the corresponding addressing properties + /// (`ironrdp_rdcleanpathurl`, `gatewayhostname`/`gatewayusagemethod`), clearing the other + /// transport's properties so the PropertySet stays consistent. + /// + /// Secrets are *not* set here: supply gateway credentials via + /// [`with_gateway_username`](Self::with_gateway_username) / + /// [`with_gateway_password`](Self::with_gateway_password) and the RDCleanPath token via + /// [`with_rdcleanpath_token`](Self::with_rdcleanpath_token). + #[must_use] + pub fn with_transport(mut self, transport: TransportKind) -> Self { match &transport { - Transport::Direct => { + TransportKind::Direct => { self.properties.clear_rdcleanpath(); - #[cfg(feature = "gateway")] self.properties.clear_gateway(); } - Transport::RDCleanPath(rdcp) => { - self.properties.set_rdcleanpath_url(rdcp.url.to_string()); - self.properties.set_rdcleanpath_token(rdcp.auth_token.clone()); - #[cfg(feature = "gateway")] + TransportKind::RDCleanPath { url } => { self.properties.clear_gateway(); + self.properties.set_rdcleanpath_url(url.to_string()); } #[cfg(feature = "gateway")] - Transport::Gateway(gw) => { + TransportKind::Gateway { endpoint } => { self.properties.clear_rdcleanpath(); - self.properties.set_gateway_hostname(gw.endpoint.clone()); + self.properties.set_gateway_hostname(endpoint.clone()); self.properties .set_gateway_usage_method(ironrdp_cfg::GatewayUsageMethod::UseAlways); - self.properties - .set_gateway_credentials(gw.username.clone(), gw.password.clone()); } } self.transport = transport; self } - /// Set the kerberos config. Upserts the `kdcproxyurl` property; `hostname` is derived from the - /// client name and not stored separately. + /// Set the RDCleanPath authentication token (only meaningful with an RDCleanPath transport). + /// + /// The token is a secret: like the gateway password, it is *not* mirrored into the PropertySet, + /// and [`build`](Self::build) strips any token loaded from a `.rdp` file before exposing + /// [`Config::properties`]. + #[must_use] + pub fn with_rdcleanpath_token(mut self, token: impl Into) -> Self { + self.rdcleanpath_token = Some(token.into()); + self + } + + /// Set the kerberos config. Upserts the `kdcproxyurl` property (or clears it when the config + /// has no KDC proxy URL); `hostname` is derived from the client name and not stored separately. #[must_use] pub fn with_kerberos_config(mut self, cfg: ironrdp_connector::credssp::KerberosConfig) -> Self { if let Some(url) = &cfg.kdc_proxy_url { self.properties.set_kdc_proxy_url(url.to_string()); + } else { + self.properties.clear_kdc_proxy_url(); } self.kerberos_config = Some(cfg); self @@ -728,6 +892,11 @@ impl ConfigBuilder { #[must_use] pub fn with_sound(mut self, enabled: bool) -> Self { self.channels.sound = enabled; + self.properties.set_audio_mode(if enabled { + ironrdp_cfg::AudioMode::RedirectToClient + } else { + ironrdp_cfg::AudioMode::Disabled + }); self } @@ -736,6 +905,8 @@ impl ConfigBuilder { #[must_use] pub fn with_clipboard(mut self, mode: ClipboardType) -> Self { self.channels.clipboard = mode; + self.properties + .set_redirect_clipboard(matches!(mode, ClipboardType::Enable)); self } @@ -744,6 +915,7 @@ impl ConfigBuilder { #[must_use] pub fn with_rdpdr(mut self, enabled: bool) -> Self { self.channels.rdpdr.enabled = enabled; + self.properties.set_enable_rdpdr(enabled); self } @@ -752,6 +924,7 @@ impl ConfigBuilder { #[must_use] pub fn with_smartcard(mut self, enabled: bool) -> Self { self.channels.rdpdr.smartcard = enabled; + self.properties.set_enable_smartcard(enabled); self } @@ -760,6 +933,7 @@ impl ConfigBuilder { #[must_use] pub fn with_qoi(mut self, enabled: bool) -> Self { self.channels.qoi = enabled; + self.properties.set_enable_qoi(enabled); self } @@ -768,14 +942,23 @@ impl ConfigBuilder { #[must_use] pub fn with_qoiz(mut self, enabled: bool) -> Self { self.channels.qoiz = enabled; + self.properties.set_enable_qoiz(enabled); self } + // TODO: It can be useful to have a method for enabling or disabling all the extra channels at once. + // Example: in tests, disable all + enable only the required channel. + /// Add a DVC pipe proxy channel. #[cfg(feature = "dvc-pipe-proxy")] #[must_use] pub fn with_dvc_pipe_proxy(mut self, info: DvcProxyInfo) -> Self { self.dvc_pipe_proxies.push(info); + self.properties + .set_dvc_pipe_proxies(self.dvc_pipe_proxies.iter().map(|p| ironrdp_cfg::DvcPipeProxy { + channel_name: p.channel_name.clone(), + pipe_name: p.pipe_name.clone(), + })); self } @@ -784,6 +967,8 @@ impl ConfigBuilder { #[must_use] pub fn with_dvc_plugin(mut self, path: impl Into) -> Self { self.dvc_plugins.push(path.into()); + self.properties + .set_dvc_plugins(self.dvc_plugins.iter().map(PathBuf::as_path)); self } @@ -842,7 +1027,7 @@ impl ConfigBuilder { missing.push(MissingField::Password); } #[cfg(feature = "gateway")] - if matches!(self.transport, Transport::Gateway(_)) { + if matches!(self.transport, TransportKind::Gateway { .. }) { if self.gateway_username.is_none() { missing.push(MissingField::GatewayUsername); } @@ -850,6 +1035,9 @@ impl ConfigBuilder { missing.push(MissingField::GatewayPassword); } } + if matches!(self.transport, TransportKind::RDCleanPath { .. }) && self.rdcleanpath_token.is_none() { + missing.push(MissingField::RDCleanPathToken); + } if self.client_build.is_none() { missing.push(MissingField::ClientBuild); } @@ -868,6 +1056,10 @@ impl ConfigBuilder { /// Build the [`Config`], filling optional settings with sensible defaults. /// /// Fails if any required field is unset; inspect [`missing`](Self::missing) beforehand to resolve them. + #[expect( + clippy::missing_panics_doc, + reason = "a panic here would be a bug (secrets are guaranteed present by missing()), not documented behavior" + )] pub fn build(self) -> anyhow::Result { use ironrdp_pdu::rdp::capability_sets::client_codecs_capabilities; use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; @@ -896,13 +1088,25 @@ impl ConfigBuilder { codecs, }; - #[cfg_attr(not(feature = "gateway"), allow(unused_mut))] - let mut transport = self.transport; - #[cfg(feature = "gateway")] - if let Transport::Gateway(gw) = &mut transport { - gw.username = self.gateway_username.unwrap_or_default(); - gw.password = self.gateway_password.unwrap_or_default(); - } + // Resolve the granular transport selection into the bundled form, folding in the separately + // tracked secrets (gateway credentials, RDCleanPath token). + #[expect( + clippy::unwrap_used, + reason = "the transport secrets are guaranteed present by the missing() check above" + )] + let transport = match self.transport { + TransportKind::Direct => Transport::Direct, + #[cfg(feature = "gateway")] + TransportKind::Gateway { endpoint } => Transport::Gateway(GatewayConfig { + endpoint, + username: self.gateway_username.unwrap(), + password: self.gateway_password.unwrap(), + }), + TransportKind::RDCleanPath { url } => Transport::RDCleanPath(RDCleanPathConfig { + url, + auth_token: self.rdcleanpath_token.unwrap(), + }), + }; let client_name = self.client_name.unwrap_or_default(); let kerberos_config = self @@ -966,6 +1170,17 @@ impl ConfigBuilder { work_dir: self.work_dir.unwrap_or_default(), }; + // To avoid easily leaking secrets, strip any known secret property before returning the resulting Config. + let mut properties = self.properties; + let detected_secrets = properties + .iter() + .filter(|(key, _)| ironrdp_cfg::is_secret_key(key)) + .map(|(key, _)| key.clone().into_owned()) + .collect::>(); + detected_secrets.into_iter().for_each(|key| { + properties.remove(&key); + }); + Ok(Config { connector, destination: self.destination.context("server address is required")?, @@ -977,7 +1192,7 @@ impl ConfigBuilder { dvc_pipe_proxies: self.dvc_pipe_proxies, #[cfg(all(windows, feature = "dvc-com-plugin"))] dvc_plugins: self.dvc_plugins, - properties: self.properties, + properties, extensions: self.extensions, }) } @@ -1073,27 +1288,43 @@ impl ConfigBuilder { // Transport: RDCleanPath > Gateway > Direct. if let Some((url, token)) = ps.rdcleanpath_url().zip(ps.rdcleanpath_token()) { let url = Url::parse(url).context("invalid 'ironrdp_rdcleanpathurl'")?; - self.transport = Transport::RDCleanPath(RDCleanPathConfig { - url, - auth_token: token.to_owned(), - }); + self.transport = TransportKind::RDCleanPath { url }; + self.rdcleanpath_token = Some(token.to_owned()); } else { #[cfg(feature = "gateway")] { - let use_gateway = ps + let gateway_usage = ps .gateway_usage_method() - .ok() - .flatten() - .map_or(ps.gateway_hostname().is_some(), GatewayUsageMethod::is_gateway_required); - if let Some(endpoint) = use_gateway.then(|| ps.gateway_hostname()).flatten() { - self.transport = Transport::Gateway(GatewayConfig { + .context("invalid Gateway usage method")? + .unwrap_or_default(); + + let gateway_hostname = ps.gateway_hostname(); + + let select_gateway_transport = match gateway_usage { + // Explicit gateway use. + GatewayUsageMethod::UseAlways => true, + + // Approximation of Windows "try direct, then gateway" behavior. + GatewayUsageMethod::Detect => gateway_hostname.is_some(), + + // IronRDP does not currently resolve MSTSC/client/GPO default gateway policy. + GatewayUsageMethod::UseDefaultSettings => false, + + // Explicit no-gateway modes. + GatewayUsageMethod::Direct | GatewayUsageMethod::DirectBypassLocal => false, + }; + + if select_gateway_transport { + let endpoint = gateway_hostname.context("missing Gateway hostname")?; + + self.transport = TransportKind::Gateway { endpoint: endpoint.to_owned(), - username: String::new(), - password: String::new(), - }); + }; + if let Some(user) = ps.gateway_username() { self.gateway_username = Some(user.to_owned()); } + if let Some(pass) = ps.gateway_password() { self.gateway_password = Some(pass.to_owned()); } @@ -1117,38 +1348,33 @@ impl ConfigBuilder { self.channels.sound = false; } #[cfg(feature = "rdpdr")] - if let Some(enabled) = ps.rdpdr_enabled() { + if let Some(enabled) = ps.enable_rdpdr() { self.channels.rdpdr.enabled = enabled; } #[cfg(feature = "smartcard")] - if let Some(enabled) = ps.smartcard_enabled() { + if let Some(enabled) = ps.enable_smartcard() { self.channels.rdpdr.smartcard = enabled; } #[cfg(feature = "qoi")] - if let Some(enabled) = ps.qoi_enabled() { + if let Some(enabled) = ps.enable_qoi() { self.channels.qoi = enabled; } #[cfg(feature = "qoiz")] - if let Some(enabled) = ps.qoiz_enabled() { + if let Some(enabled) = ps.enable_qoiz() { self.channels.qoiz = enabled; } #[cfg(feature = "dvc-pipe-proxy")] - for proxy in ps.dvc_pipe_proxies().into_iter().flat_map(|s| s.split(',')) { - let proxy = proxy.trim(); - if !proxy.is_empty() { - self.dvc_pipe_proxies - .push(proxy.parse().context("invalid DVC pipe proxy spec")?); - } + for (idx, proxy) in ps.dvc_pipe_proxies().enumerate() { + let proxy = proxy.with_context(|| format!("invalid DVC pipe proxy spec at idx {idx}"))?; + self.dvc_pipe_proxies.push(DvcProxyInfo { + channel_name: proxy.channel_name, + pipe_name: proxy.pipe_name, + }); } #[cfg(all(windows, feature = "dvc-com-plugin"))] - for plugin in ps.dvc_plugins().into_iter().flat_map(|s| s.split(',')) { - let plugin = plugin.trim(); - if !plugin.is_empty() { - self.dvc_plugins.push(PathBuf::from(plugin)); - } - } + self.dvc_plugins.extend(ps.dvc_plugins()); Ok(self) } diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 43f001d80e..929fa99601 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -406,7 +406,7 @@ fn build_connector( connector = connector.with_static_channel(rdpdr_channel); } - // Attach CLIPRDR (clipboard redirection). The backend is built fresh per connection. + // Attach CLIPRDR (clipboard redirection). The backend is built fresh per connection. #[cfg(feature = "clipboard")] if let Some(factory) = cliprdr_factory { let backend = factory.build_cliprdr_backend(); diff --git a/crates/ironrdp-pdu/src/macros.rs b/crates/ironrdp-pdu/src/macros.rs index 2bbfda73b0..9b5aaf4ddf 100644 --- a/crates/ironrdp-pdu/src/macros.rs +++ b/crates/ironrdp-pdu/src/macros.rs @@ -48,6 +48,7 @@ macro_rules! const_assert { }; } +// TODO: move to ironrdp-core crate. /// Implements additional traits for a plain old data structure (POD). #[macro_export] macro_rules! impl_pdu_pod { @@ -88,6 +89,7 @@ macro_rules! impl_x224_pdu_pod { }; } +// TODO: move to ironrdp-core crate. /// Implements additional traits for a borrowing PDU and defines a static-bounded owned version. #[macro_export] macro_rules! impl_pdu_borrowing { diff --git a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs b/crates/ironrdp-testsuite-extra/tests/client_config.rs similarity index 99% rename from crates/ironrdp-testsuite-extra/tests/config_rdp.rs rename to crates/ironrdp-testsuite-extra/tests/client_config.rs index 03a23dd603..7614f86e9f 100644 --- a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs +++ b/crates/ironrdp-testsuite-extra/tests/client_config.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::PathBuf; use ironrdp_client::config::{ClipboardType, Transport}; -use ironrdp_viewer::config::parse_config_from; +use ironrdp_viewer::cli::parse_config_from; use uuid::Uuid; struct TempRdpFile { diff --git a/crates/ironrdp-testsuite-extra/tests/e2e.rs b/crates/ironrdp-testsuite-extra/tests/e2e.rs new file mode 100644 index 0000000000..04dfbbd45e --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/e2e.rs @@ -0,0 +1,396 @@ +// FIXME: tests in this module can probably be rewritten to be much shorter using the ironrdp-client crate. + +use core::time::Duration; +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::Result; +use ironrdp::connector; +use ironrdp::dvc::DrdynvcClient; +use ironrdp::echo::client::EchoClient; +use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; +use ironrdp::pdu::{self, gcc}; +use ironrdp::server::{ + self, DesktopSize, DisplayUpdate, KeyboardEvent, MouseEvent, PixelFormat, RdpServer, RdpServerDisplay, + RdpServerDisplayUpdates, RdpServerInputHandler, ServerEvent, TlsIdentityCtx, +}; +use ironrdp::session::image::DecodedImage; +use ironrdp::session::{self, ActiveStage, ActiveStageOutput}; +use ironrdp_async::{Framed, FramedWrite as _}; +use ironrdp_testsuite_extra as _; +use ironrdp_tls::TlsStream; +use ironrdp_tokio::TokioStream; +use tokio::net::TcpStream; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::sync::{Mutex, oneshot}; +use tracing::debug; + +const DESKTOP_WIDTH: u16 = 1024; +const DESKTOP_HEIGHT: u16 = 768; +const USERNAME: &str = ""; +const PASSWORD: &str = ""; + +#[tokio::test] +async fn test_client_server() { + client_server(default_client_config(), |stage, framed, _display_tx| async { + (stage, framed) + }) + .await +} + +#[tokio::test] +async fn test_deactivation_reactivation() { + let client_config = default_client_config(); + let mut image = DecodedImage::new( + PixelFormat::RgbA32, + client_config.desktop_size.width, + client_config.desktop_size.height, + ); + client_server(client_config, |mut stage, mut framed, display_tx| async move { + display_tx + .send(DisplayUpdate::Resize(DesktopSize { + width: 2048, + height: 2048, + })) + .unwrap(); + { + let (action, payload) = framed.read_pdu().await.expect("valid PDU"); + let outputs = stage.process(&mut image, action, &payload).expect("stage process"); + let out = outputs.into_iter().next().unwrap(); + match out { + ActiveStageOutput::DeactivateAll(mut connection_activation) => { + // TODO: factor this out in common client code + // Execute the Deactivation-Reactivation Sequence: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 + debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); + let mut buf = pdu::WriteBuf::new(); + 'activation_seq: loop { + let written = ironrdp_async::single_sequence_step_read( + &mut framed, + &mut *connection_activation, + &mut buf, + ) + .await + .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e)) + .unwrap(); + + if written.size().is_some() { + framed + .write_all(buf.filled()) + .await + .map_err(|e| session::custom_err!("write deactivation-reactivation sequence step", e)) + .unwrap(); + } + + if let connector::connection_activation::ConnectionActivationState::Finalized { + io_channel_id, + user_channel_id, + desktop_size, + share_id, + enable_server_pointer, + pointer_software_rendering, + } = connection_activation.connection_activation_state() + { + debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); + // Update image size with the new desktop size. + // image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); + // Update the active stage with the new channel IDs and pointer settings. + stage.set_fastpath_processor( + session::fast_path::ProcessorBuilder { + io_channel_id, + user_channel_id, + share_id, + enable_server_pointer, + pointer_software_rendering, + bulk_decompressor: None, + } + .build(), + ); + stage.set_share_id(share_id); + stage.set_enable_server_pointer(enable_server_pointer); + break 'activation_seq; + } + } + } + _ => unreachable!(), + } + } + (stage, framed) + }) + .await +} + +#[tokio::test] +async fn test_echo_virtual_channel_end_to_end() { + let payload = b"ironrdp echo e2e".to_vec(); + let echo_payload = payload.clone(); + + client_server_with_connector( + default_client_config(), + |connector| connector.with_static_channel(DrdynvcClient::new().with_dynamic_channel(EchoClient::new())), + move |mut stage, mut framed, display_tx, echo_handle| async move { + let _display_tx = display_tx; + let mut image = DecodedImage::new(PixelFormat::RgbA32, DESKTOP_WIDTH, DESKTOP_HEIGHT); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut matched_measurement = None; + + while Instant::now() < deadline { + echo_handle + .send_request(echo_payload.clone()) + .expect("send echo request"); + + for _ in 0..20 { + let measurements = echo_handle.take_measurements(); + if let Some(measurement) = measurements.into_iter().find(|m| m.payload == echo_payload) { + matched_measurement = Some(measurement); + break; + } + + let read_result = tokio::time::timeout(Duration::from_millis(150), framed.read_pdu()).await; + let Ok(Ok((action, frame))) = read_result else { + continue; + }; + + let outputs = stage.process(&mut image, action, &frame).expect("stage process"); + for output in outputs { + if let ActiveStageOutput::ResponseFrame(frame) = output { + framed.write_all(&frame).await.expect("write response frame"); + } + } + } + + if matched_measurement.is_some() { + break; + } + } + + let measurement = matched_measurement.expect("echo RTT measurement was not produced"); + assert_eq!(measurement.payload, echo_payload); + + (stage, framed) + }, + ) + .await +} + +type DisplayUpdatesRx = Arc>>; + +struct TestDisplayUpdates { + rx: DisplayUpdatesRx, +} + +#[async_trait::async_trait] +impl RdpServerDisplayUpdates for TestDisplayUpdates { + async fn next_update(&mut self) -> Result> { + let mut rx = self.rx.lock().await; + + Ok(rx.recv().await) + } +} + +struct TestDisplay { + rx: DisplayUpdatesRx, +} + +#[async_trait::async_trait] +impl RdpServerDisplay for TestDisplay { + async fn size(&mut self) -> DesktopSize { + DesktopSize { + width: DESKTOP_WIDTH, + height: DESKTOP_HEIGHT, + } + } + + async fn updates(&mut self) -> Result> { + Ok(Box::new(TestDisplayUpdates { + rx: Arc::clone(&self.rx), + })) + } +} + +struct TestInputHandler; +impl RdpServerInputHandler for TestInputHandler { + fn keyboard(&mut self, _: KeyboardEvent) {} + fn mouse(&mut self, _: MouseEvent) {} +} + +async fn client_server(client_config: connector::Config, clientfn: F) +where + F: FnOnce(ActiveStage, Framed>>, UnboundedSender) -> Fut + 'static, + Fut: Future>>)>, +{ + client_server_with_connector( + client_config, + |connector| connector, + move |stage, framed, display_tx, _echo_handle| clientfn(stage, framed, display_tx), + ) + .await; +} + +async fn client_server_with_connector(client_config: connector::Config, connector_factory: C, clientfn: F) +where + F: FnOnce( + ActiveStage, + Framed>>, + UnboundedSender, + server::EchoServerHandle, + ) -> Fut + + 'static, + Fut: Future>>)>, + C: FnOnce(connector::ClientConnector) -> connector::ClientConnector + 'static, +{ + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); + + let cert_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/certs/server-cert.pem"); + let key_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/certs/server-key.pem"); + let identity = TlsIdentityCtx::init_from_paths(&cert_path, &key_path).expect("failed to init TLS identity"); + let acceptor = identity.make_acceptor().expect("failed to build TLS acceptor"); + + let (display_tx, display_rx) = mpsc::unbounded_channel(); + let mut server = RdpServer::builder() + .with_addr(([127, 0, 0, 1], 0)) + .with_tls(acceptor) + .with_input_handler(TestInputHandler) + .with_display_handler(TestDisplay { + rx: Arc::new(Mutex::new(display_rx)), + }) + .build(); + server.set_credentials(Some(server::Credentials { + username: USERNAME.into(), + password: PASSWORD.into(), + domain: None, + })); + let ev = server.event_sender().clone(); + let echo_handle = server.echo_handle().clone(); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let server = tokio::task::spawn_local(async move { + server.run().await.unwrap(); + }); + + let client = tokio::task::spawn_local(async move { + let (tx, rx) = oneshot::channel(); + ev.send(ServerEvent::GetLocalAddr(tx)).unwrap(); + let server_addr = rx.await.unwrap().unwrap(); + let tcp_stream = TcpStream::connect(server_addr).await.expect("TCP connect"); + let client_addr = tcp_stream.local_addr().expect("local_addr"); + let mut framed = ironrdp_tokio::TokioFramed::new(tcp_stream); + let connector = connector::ClientConnector::new(client_config, client_addr); + let mut connector = connector_factory(connector); + let should_upgrade = ironrdp_async::connect_begin(&mut framed, &mut connector) + .await + .expect("begin connection"); + let initial_stream = framed.into_inner_no_leftover(); + let (upgraded_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, "localhost") + .await + .expect("TLS upgrade"); + let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); + let mut upgraded_framed = ironrdp_tokio::TokioFramed::new(upgraded_stream); + let server_public_key = + ironrdp_tls::extract_tls_server_public_key(&tls_cert).expect("extract server public key"); + let connection_result = ironrdp_async::connect_finalize( + upgraded, + connector, + &mut upgraded_framed, + &mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(), + "localhost".into(), + server_public_key.to_owned(), + None, + ) + .await + .expect("finalize connection"); + + let active_stage = ActiveStage::new(connection_result); + let (active_stage, mut upgraded_framed) = + clientfn(active_stage, upgraded_framed, display_tx, echo_handle).await; + let outputs = active_stage.graceful_shutdown().expect("shutdown"); + for out in outputs { + match out { + ActiveStageOutput::ResponseFrame(frame) => { + upgraded_framed.write_all(&frame).await.expect("write frame"); + } + _ => unimplemented!(), + } + } + + // server should probably send TLS close_notify + while let Ok(pdu) = upgraded_framed.read_pdu().await { + debug!(?pdu); + } + ev.send(ServerEvent::Quit("bye".into())).unwrap(); + }); + + tokio::try_join!(server, client).expect("join"); + }) + .await; +} + +fn default_client_config() -> connector::Config { + connector::Config { + desktop_size: DesktopSize { + width: DESKTOP_WIDTH, + height: DESKTOP_HEIGHT, + }, + desktop_scale_factor: 0, // Default to 0 per FreeRDP + enable_tls: true, + enable_credssp: true, + credentials: connector::Credentials::UsernamePassword { + username: USERNAME.into(), + password: PASSWORD.into(), + }, + domain: None, + client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) + .map(|version| version.major * 100 + version.minor * 10 + version.patch) + .unwrap_or(0) + .try_into() + .unwrap(), + client_name: "ironrdp".into(), + keyboard_type: gcc::KeyboardType::IbmEnhanced, + keyboard_subtype: 0, + keyboard_layout: 0, + keyboard_functional_keys_count: 12, + ime_file_name: "".into(), + bitmap: None, + dig_product_id: "".into(), + // NOTE: hardcode this value like in freerdp + // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 + client_dir: "C:\\Windows\\System32\\mstscax.dll".into(), + #[cfg(windows)] + platform: MajorPlatformType::WINDOWS, + #[cfg(target_os = "macos")] + platform: MajorPlatformType::MACINTOSH, + #[cfg(target_os = "ios")] + platform: MajorPlatformType::IOS, + #[cfg(target_os = "linux")] + platform: MajorPlatformType::UNIX, + #[cfg(target_os = "android")] + platform: MajorPlatformType::ANDROID, + #[cfg(target_os = "freebsd")] + platform: MajorPlatformType::UNIX, + #[cfg(target_os = "dragonfly")] + platform: MajorPlatformType::UNIX, + #[cfg(target_os = "openbsd")] + platform: MajorPlatformType::UNIX, + #[cfg(target_os = "netbsd")] + platform: MajorPlatformType::UNIX, + hardware_id: None, + request_data: None, + autologon: false, + enable_audio_playback: true, + license_cache: None, + compression_type: None, + enable_server_pointer: true, + pointer_software_rendering: true, + multitransport_flags: None, + performance_flags: Default::default(), + timezone_info: Default::default(), + alternate_shell: String::new(), + work_dir: String::new(), + } +} diff --git a/crates/ironrdp-testsuite-extra/tests/main.rs b/crates/ironrdp-testsuite-extra/tests/main.rs index 8c6f66dc36..bc775d3cde 100644 --- a/crates/ironrdp-testsuite-extra/tests/main.rs +++ b/crates/ironrdp-testsuite-extra/tests/main.rs @@ -1,400 +1,5 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary #![allow(clippy::unwrap_used, reason = "unwrap is fine in tests")] -mod config_rdp; - -use core::time::Duration; -use std::path::Path; -use std::sync::Arc; -use std::time::Instant; - -use anyhow::Result; -use ironrdp::connector; -use ironrdp::dvc::DrdynvcClient; -use ironrdp::echo::client::EchoClient; -use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; -use ironrdp::pdu::{self, gcc}; -use ironrdp::server::{ - self, DesktopSize, DisplayUpdate, KeyboardEvent, MouseEvent, PixelFormat, RdpServer, RdpServerDisplay, - RdpServerDisplayUpdates, RdpServerInputHandler, ServerEvent, TlsIdentityCtx, -}; -use ironrdp::session::image::DecodedImage; -use ironrdp::session::{self, ActiveStage, ActiveStageOutput}; -use ironrdp_async::{Framed, FramedWrite as _}; -use ironrdp_testsuite_extra as _; -use ironrdp_tls::TlsStream; -use ironrdp_tokio::TokioStream; -use tokio::net::TcpStream; -use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; -use tokio::sync::{Mutex, oneshot}; -use tracing::debug; - -const DESKTOP_WIDTH: u16 = 1024; -const DESKTOP_HEIGHT: u16 = 768; -const USERNAME: &str = ""; -const PASSWORD: &str = ""; - -#[tokio::test] -async fn test_client_server() { - client_server(default_client_config(), |stage, framed, _display_tx| async { - (stage, framed) - }) - .await -} - -#[tokio::test] -async fn test_deactivation_reactivation() { - let client_config = default_client_config(); - let mut image = DecodedImage::new( - PixelFormat::RgbA32, - client_config.desktop_size.width, - client_config.desktop_size.height, - ); - client_server(client_config, |mut stage, mut framed, display_tx| async move { - display_tx - .send(DisplayUpdate::Resize(DesktopSize { - width: 2048, - height: 2048, - })) - .unwrap(); - { - let (action, payload) = framed.read_pdu().await.expect("valid PDU"); - let outputs = stage.process(&mut image, action, &payload).expect("stage process"); - let out = outputs.into_iter().next().unwrap(); - match out { - ActiveStageOutput::DeactivateAll(mut connection_activation) => { - // TODO: factor this out in common client code - // Execute the Deactivation-Reactivation Sequence: - // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 - debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); - let mut buf = pdu::WriteBuf::new(); - 'activation_seq: loop { - let written = ironrdp_async::single_sequence_step_read( - &mut framed, - &mut *connection_activation, - &mut buf, - ) - .await - .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e)) - .unwrap(); - - if written.size().is_some() { - framed - .write_all(buf.filled()) - .await - .map_err(|e| session::custom_err!("write deactivation-reactivation sequence step", e)) - .unwrap(); - } - - if let connector::connection_activation::ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, - desktop_size, - share_id, - enable_server_pointer, - pointer_software_rendering, - } = connection_activation.connection_activation_state() - { - debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); - // Update image size with the new desktop size. - // image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); - // Update the active stage with the new channel IDs and pointer settings. - stage.set_fastpath_processor( - session::fast_path::ProcessorBuilder { - io_channel_id, - user_channel_id, - share_id, - enable_server_pointer, - pointer_software_rendering, - bulk_decompressor: None, - } - .build(), - ); - stage.set_share_id(share_id); - stage.set_enable_server_pointer(enable_server_pointer); - break 'activation_seq; - } - } - } - _ => unreachable!(), - } - } - (stage, framed) - }) - .await -} - -#[tokio::test] -async fn test_echo_virtual_channel_end_to_end() { - let payload = b"ironrdp echo e2e".to_vec(); - let echo_payload = payload.clone(); - - client_server_with_connector( - default_client_config(), - |connector| connector.with_static_channel(DrdynvcClient::new().with_dynamic_channel(EchoClient::new())), - move |mut stage, mut framed, display_tx, echo_handle| async move { - let _display_tx = display_tx; - let mut image = DecodedImage::new(PixelFormat::RgbA32, DESKTOP_WIDTH, DESKTOP_HEIGHT); - - let deadline = Instant::now() + Duration::from_secs(5); - let mut matched_measurement = None; - - while Instant::now() < deadline { - echo_handle - .send_request(echo_payload.clone()) - .expect("send echo request"); - - for _ in 0..20 { - let measurements = echo_handle.take_measurements(); - if let Some(measurement) = measurements.into_iter().find(|m| m.payload == echo_payload) { - matched_measurement = Some(measurement); - break; - } - - let read_result = tokio::time::timeout(Duration::from_millis(150), framed.read_pdu()).await; - let Ok(Ok((action, frame))) = read_result else { - continue; - }; - - let outputs = stage.process(&mut image, action, &frame).expect("stage process"); - for output in outputs { - if let ActiveStageOutput::ResponseFrame(frame) = output { - framed.write_all(&frame).await.expect("write response frame"); - } - } - } - - if matched_measurement.is_some() { - break; - } - } - - let measurement = matched_measurement.expect("echo RTT measurement was not produced"); - assert_eq!(measurement.payload, echo_payload); - - (stage, framed) - }, - ) - .await -} - -type DisplayUpdatesRx = Arc>>; - -struct TestDisplayUpdates { - rx: DisplayUpdatesRx, -} - -#[async_trait::async_trait] -impl RdpServerDisplayUpdates for TestDisplayUpdates { - async fn next_update(&mut self) -> Result> { - let mut rx = self.rx.lock().await; - - Ok(rx.recv().await) - } -} - -struct TestDisplay { - rx: DisplayUpdatesRx, -} - -#[async_trait::async_trait] -impl RdpServerDisplay for TestDisplay { - async fn size(&mut self) -> DesktopSize { - DesktopSize { - width: DESKTOP_WIDTH, - height: DESKTOP_HEIGHT, - } - } - - async fn updates(&mut self) -> Result> { - Ok(Box::new(TestDisplayUpdates { - rx: Arc::clone(&self.rx), - })) - } -} - -struct TestInputHandler; -impl RdpServerInputHandler for TestInputHandler { - fn keyboard(&mut self, _: KeyboardEvent) {} - fn mouse(&mut self, _: MouseEvent) {} -} - -async fn client_server(client_config: connector::Config, clientfn: F) -where - F: FnOnce(ActiveStage, Framed>>, UnboundedSender) -> Fut + 'static, - Fut: Future>>)>, -{ - client_server_with_connector( - client_config, - |connector| connector, - move |stage, framed, display_tx, _echo_handle| clientfn(stage, framed, display_tx), - ) - .await; -} - -async fn client_server_with_connector(client_config: connector::Config, connector_factory: C, clientfn: F) -where - F: FnOnce( - ActiveStage, - Framed>>, - UnboundedSender, - server::EchoServerHandle, - ) -> Fut - + 'static, - Fut: Future>>)>, - C: FnOnce(connector::ClientConnector) -> connector::ClientConnector + 'static, -{ - let _ = tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .try_init(); - - let cert_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/certs/server-cert.pem"); - let key_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/certs/server-key.pem"); - let identity = TlsIdentityCtx::init_from_paths(&cert_path, &key_path).expect("failed to init TLS identity"); - let acceptor = identity.make_acceptor().expect("failed to build TLS acceptor"); - - let (display_tx, display_rx) = mpsc::unbounded_channel(); - let mut server = RdpServer::builder() - .with_addr(([127, 0, 0, 1], 0)) - .with_tls(acceptor) - .with_input_handler(TestInputHandler) - .with_display_handler(TestDisplay { - rx: Arc::new(Mutex::new(display_rx)), - }) - .build(); - server.set_credentials(Some(server::Credentials { - username: USERNAME.into(), - password: PASSWORD.into(), - domain: None, - })); - let ev = server.event_sender().clone(); - let echo_handle = server.echo_handle().clone(); - - let local = tokio::task::LocalSet::new(); - local - .run_until(async move { - let server = tokio::task::spawn_local(async move { - server.run().await.unwrap(); - }); - - let client = tokio::task::spawn_local(async move { - let (tx, rx) = oneshot::channel(); - ev.send(ServerEvent::GetLocalAddr(tx)).unwrap(); - let server_addr = rx.await.unwrap().unwrap(); - let tcp_stream = TcpStream::connect(server_addr).await.expect("TCP connect"); - let client_addr = tcp_stream.local_addr().expect("local_addr"); - let mut framed = ironrdp_tokio::TokioFramed::new(tcp_stream); - let connector = connector::ClientConnector::new(client_config, client_addr); - let mut connector = connector_factory(connector); - let should_upgrade = ironrdp_async::connect_begin(&mut framed, &mut connector) - .await - .expect("begin connection"); - let initial_stream = framed.into_inner_no_leftover(); - let (upgraded_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, "localhost") - .await - .expect("TLS upgrade"); - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); - let mut upgraded_framed = ironrdp_tokio::TokioFramed::new(upgraded_stream); - let server_public_key = - ironrdp_tls::extract_tls_server_public_key(&tls_cert).expect("extract server public key"); - let connection_result = ironrdp_async::connect_finalize( - upgraded, - connector, - &mut upgraded_framed, - &mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(), - "localhost".into(), - server_public_key.to_owned(), - None, - ) - .await - .expect("finalize connection"); - - let active_stage = ActiveStage::new(connection_result); - let (active_stage, mut upgraded_framed) = - clientfn(active_stage, upgraded_framed, display_tx, echo_handle).await; - let outputs = active_stage.graceful_shutdown().expect("shutdown"); - for out in outputs { - match out { - ActiveStageOutput::ResponseFrame(frame) => { - upgraded_framed.write_all(&frame).await.expect("write frame"); - } - _ => unimplemented!(), - } - } - - // server should probably send TLS close_notify - while let Ok(pdu) = upgraded_framed.read_pdu().await { - debug!(?pdu); - } - ev.send(ServerEvent::Quit("bye".into())).unwrap(); - }); - - tokio::try_join!(server, client).expect("join"); - }) - .await; -} - -// Maybe implement Default for Config -fn default_client_config() -> connector::Config { - connector::Config { - desktop_size: DesktopSize { - width: DESKTOP_WIDTH, - height: DESKTOP_HEIGHT, - }, - desktop_scale_factor: 0, // Default to 0 per FreeRDP - enable_tls: true, - enable_credssp: true, - credentials: connector::Credentials::UsernamePassword { - username: USERNAME.into(), - password: PASSWORD.into(), - }, - domain: None, - client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map(|version| version.major * 100 + version.minor * 10 + version.patch) - .unwrap_or(0) - .try_into() - .unwrap(), - client_name: "ironrdp".into(), - keyboard_type: gcc::KeyboardType::IbmEnhanced, - keyboard_subtype: 0, - keyboard_layout: 0, - keyboard_functional_keys_count: 12, - ime_file_name: "".into(), - bitmap: None, - dig_product_id: "".into(), - // NOTE: hardcode this value like in freerdp - // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 - client_dir: "C:\\Windows\\System32\\mstscax.dll".into(), - #[cfg(windows)] - platform: MajorPlatformType::WINDOWS, - #[cfg(target_os = "macos")] - platform: MajorPlatformType::MACINTOSH, - #[cfg(target_os = "ios")] - platform: MajorPlatformType::IOS, - #[cfg(target_os = "linux")] - platform: MajorPlatformType::UNIX, - #[cfg(target_os = "android")] - platform: MajorPlatformType::ANDROID, - #[cfg(target_os = "freebsd")] - platform: MajorPlatformType::UNIX, - #[cfg(target_os = "dragonfly")] - platform: MajorPlatformType::UNIX, - #[cfg(target_os = "openbsd")] - platform: MajorPlatformType::UNIX, - #[cfg(target_os = "netbsd")] - platform: MajorPlatformType::UNIX, - hardware_id: None, - request_data: None, - autologon: false, - enable_audio_playback: true, - license_cache: None, - compression_type: None, - enable_server_pointer: true, - pointer_software_rendering: true, - multitransport_flags: None, - performance_flags: Default::default(), - timezone_info: Default::default(), - alternate_shell: String::new(), - work_dir: String::new(), - } -} +mod client_config; +mod e2e; diff --git a/crates/ironrdp-viewer/src/config.rs b/crates/ironrdp-viewer/src/cli.rs similarity index 65% rename from crates/ironrdp-viewer/src/config.rs rename to crates/ironrdp-viewer/src/cli.rs index 7909d76d0c..c03a4a0e39 100644 --- a/crates/ironrdp-viewer/src/config.rs +++ b/crates/ironrdp-viewer/src/cli.rs @@ -1,6 +1,6 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] -use core::num::ParseIntError; +use core::time::Duration; use std::path::PathBuf; use anyhow::Context as _; @@ -8,6 +8,7 @@ use clap::Parser; use clap::clap_derive::ValueEnum; use ironrdp::client::config::{ ClipboardType as ResolvedClipboardType, Config, ConfigBuilder, Destination, DvcProxyInfo, MissingField, + TransportKind, }; use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; use ironrdp_cfg::PropertySetExt as _; @@ -52,128 +53,6 @@ impl KeyboardType { } } -fn apply_cli_args_to_properties(properties: &mut ironrdp_propertyset::PropertySet, args: &Args) { - if let Some(dest) = &args.destination { - // Format the host in .rdp canonical form: IPv6 gets bracketed ("[::1]"), others are plain. - let host = dest - .name() - .parse::() - .map(ironrdp_cfg::TargetHost::Ip) - .unwrap_or_else(|_| ironrdp_cfg::TargetHost::Domain(dest.name().to_owned())); - properties.insert("full address", format!("{host}:{}", dest.port())); - } - - if let Some(username) = &args.username { - properties.insert("username", username.as_str()); - } - - if let Some(password) = &args.password { - properties.insert("ClearTextPassword", password.as_str()); - } - - if let Some(domain) = &args.domain { - properties.insert("domain", domain.as_str()); - } - - if let Some(scale) = args.scale_desktop { - properties.insert("desktopscalefactor", i64::from(scale)); - } - - if let Some(width) = args.desktop_width { - properties.insert("desktopwidth", i64::from(width)); - } - - if let Some(height) = args.desktop_height { - properties.insert("desktopheight", i64::from(height)); - } - - if let Some(gw_host) = &args.gw_endpoint { - properties.insert("gatewayhostname", gw_host.as_str()); - // Ensure the gateway is treated as enabled when a host is provided explicitly. - properties.insert( - "gatewayusagemethod", - ironrdp_cfg::GatewayUsageMethod::UseAlways.as_i64(), - ); - } - - if let Some(gw_user) = &args.gw_user { - properties.insert("gatewayusername", gw_user.as_str()); - } - - if let Some(gw_pass) = &args.gw_pass { - properties.insert("GatewayPassword", gw_pass.as_str()); - } - - if args.no_credssp { - properties.set_enable_credssp_support(false); - } - - if args.no_tls { - properties.set_enable_tls(false); - } - - if args.no_server_pointer { - properties.set_server_pointer(false); - } - - if args.autologon { - properties.set_autologon(true); - } - - if let Some(enabled) = args.compression_enabled { - properties.set_compression(enabled); - } - - if let Some(level) = args.compression_level { - properties.set_compression_level(level); - } - - if let Some(color_depth) = args.color_depth { - properties.set_color_depth(color_depth); - } - - #[cfg(windows)] - if !args.dvc_plugin.is_empty() { - let value = args - .dvc_plugin - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(","); - properties.set_dvc_plugins(value); - } - - if let Some(url) = &args.rdcleanpath_url { - properties.set_rdcleanpath_url(url.as_str()); - } - - if let Some(token) = &args.rdcleanpath_token { - properties.set_rdcleanpath_token(token.as_str()); - } - - if let Some(minutes) = args.prevent_session_lock { - properties.set_fake_events_interval(minutes); - } - - if !args.dvc_proxy.is_empty() { - let value = args - .dvc_proxy - .iter() - .map(|p| format!("{}={}", p.channel_name, p.pipe_name)) - .collect::>() - .join(","); - properties.set_dvc_pipe_proxies(value); - } -} - -fn parse_hex(input: &str) -> Result { - if input.starts_with("0x") { - u32::from_str_radix(input.get(2..).unwrap_or(""), 16) - } else { - input.parse::() - } -} - /// Devolutions IronRDP viewer #[derive(Parser, Debug)] #[clap(author = "Devolutions", about = "Devolutions-IronRDP viewer")] @@ -210,7 +89,9 @@ struct Args { password: Option, /// Proxy URL to connect to for the RDCleanPath - #[clap(long, requires("rdcleanpath_token"))] + /// + /// The accompanying token may be supplied via `--rdcleanpath-token` or entered interactively. + #[clap(long)] rdcleanpath_url: Option, /// Authentication token to insert in the RDCleanPath packet @@ -237,14 +118,6 @@ struct Args { #[clap(long, default_value_t = String::from(""))] dig_product_id: String, - /// Enable thin client - #[clap(long)] - thin_client: bool, - - /// Enable small cache - #[clap(long)] - small_cache: bool, - /// Scaling factor for desktop applications, percentage (value between 100 and 500) #[clap(long, value_parser = clap::value_parser!(u32).range(100..=500))] scale_desktop: Option, @@ -266,11 +139,6 @@ struct Args { #[clap(long)] no_server_pointer: bool, - /// Enabled capability versions. Each bit represents enabling a capability version - /// starting from V8 to V10_7 - #[clap(long, value_parser = parse_hex, default_value_t = 0)] - capabilities: u32, - /// Automatically logon to the server by passing the INFO_AUTOLOGON flag /// /// This flag is ignored if CredSSP authentication is used. @@ -299,14 +167,14 @@ struct Args { #[clap(long, num_args = 1.., value_delimiter = ',')] codecs: Vec, - /// Enable bulk compression support (default: true). + /// Disable bulk compression support. /// - /// When enabled, the client advertises support for bulk compression and the - /// server may send compressed PDUs. Use `--compression-enabled=false` to - /// disable. When not specified, the value from the `.rdp` file is used (if - /// present), otherwise compression is enabled by default. - #[clap(long, action = clap::ArgAction::Set)] - compression_enabled: Option, + /// By default the client advertises support for bulk compression and the + /// server may send compressed PDUs. Pass `--no-compression` to disable it. + /// When not specified, the value from the `.rdp` file is used (if present), + /// otherwise compression is enabled by default. + #[clap(long)] + no_compression: bool, /// Bulk compression level to negotiate with the server. /// @@ -346,32 +214,20 @@ struct Args { dump_rdp: Option, } -/// The result of phase 1 parsing: the merged PropertySet plus CLI-only settings. +/// Result of parsing CLI args + loading the `.rdp` file: a configured [`ConfigBuilder`] plus the +/// CLI-only settings that cannot live on the builder. /// -/// After obtaining a `PartialConfig`, callers may inspect or serialise [`PartialConfig::properties`] -/// (e.g., with the `--dump-rdp` flag) before committing to a full session. Call -/// [`PartialConfig::into_config`] to complete phase 2 (interactive prompts + strong typing). -#[derive(Debug)] -pub struct PartialConfig { - /// The merged PropertySet (`.rdp` file + CLI overrides). - pub properties: ironrdp_propertyset::PropertySet, +/// Call [`ViewerConfig::into_config`] to resolve the remaining required fields (interactive prompts +/// + frontend-derived client identity) and build the strongly-typed [`Config`]. +pub struct ViewerConfig { + builder: ConfigBuilder, // CLI-only settings that are not representable as `.rdp` file properties. - pub log_file: Option, - pub dump_rdp: Option, - pub keyboard_type: KeyboardType, - pub keyboard_subtype: u32, - pub keyboard_functional_keys_count: u32, - pub ime_file_name: String, - pub dig_product_id: String, - pub thin_client: bool, - pub small_cache: bool, - pub capabilities: u32, - pub clipboard_type: ClipboardType, - pub codecs: Vec, + log_file: Option, + dump_rdp: Option, } -impl PartialConfig { +impl ViewerConfig { pub fn parse_args() -> anyhow::Result { Self::parse_from(std::env::args_os()) } @@ -396,59 +252,156 @@ impl PartialConfig { } } - // CLI arguments take precedence: upsert them after the .rdp file is loaded. - apply_cli_args_to_properties(&mut properties, &args); + let log_file = args.log_file.clone(); + let dump_rdp = args.dump_rdp.clone(); + + // The library overlays everything expressible as a `.rdp` property: destination, credentials, + // transport, channels, desktop size, audio, DVC proxies, etc. + let builder = ConfigBuilder::from_property_set(&properties)?; + + // Whether the `.rdp` file requested clipboard redirection; the CLI `--clipboard-type` is + // resolved against this when applied below. + let redirect_clipboard = properties.redirect_clipboard().unwrap_or(true); + + // CLI arguments take precedence: apply them on top of the `.rdp`-derived builder. + let builder = apply_cli_to_builder(builder, args, redirect_clipboard); Ok(Self { - properties, - log_file: args.log_file, - dump_rdp: args.dump_rdp, - keyboard_type: args.keyboard_type, - keyboard_subtype: args.keyboard_subtype, - keyboard_functional_keys_count: args.keyboard_functional_keys_count, - ime_file_name: args.ime_file_name, - dig_product_id: args.dig_product_id, - thin_client: args.thin_client, - small_cache: args.small_cache, - capabilities: args.capabilities, - clipboard_type: args.clipboard_type, - codecs: args.codecs, + builder, + log_file, + dump_rdp, }) } pub fn into_config(self) -> anyhow::Result { - use ironrdp_cfg::PropertySetExt as _; + // When dumping, the built config is only used to observe the effective, secret-stripped + // PropertySet; we never start a session. Secrets are stripped on `build()` anyway, so there + // is no point prompting for them: fill a placeholder instead. + let dump = self.dump_rdp.is_some(); + prompt_missing(self.builder, dump) + } - // The library overlays everything expressible as a `.rdp` property: destination, credentials, - // transport, channels, desktop size, audio, DVC proxies, etc. - let mut builder = ConfigBuilder::from_property_set(&self.properties)?; - - // CLI-only knobs that are not representable as `.rdp` properties. - builder = builder - .with_keyboard_type(self.keyboard_type.into_pdu()) - .with_keyboard_subtype(self.keyboard_subtype) - .with_keyboard_functional_keys_count(self.keyboard_functional_keys_count) - .with_ime_file_name(self.ime_file_name) - .with_dig_product_id(self.dig_product_id) - .with_codecs(self.codecs.clone()); - - // Validate the codecs early to surface help text before connecting. - let codecs: Vec<_> = self.codecs.iter().map(String::as_str).collect(); + /// Path to the log file requested on the CLI, if any. + pub fn log_file(&self) -> Option<&str> { + self.log_file.as_deref() + } + + /// Path to dump the effective `.rdp` PropertySet to, if `--dump-rdp` was given. + pub fn dump_rdp(&self) -> Option<&std::path::Path> { + self.dump_rdp.as_deref() + } +} + +/// Apply CLI overrides on top of a builder that already reflects the `.rdp` file. Every flag that is +/// present overwrites the corresponding builder (and mirrored property) value. +fn apply_cli_to_builder(mut builder: ConfigBuilder, args: Args, redirect_clipboard: bool) -> ConfigBuilder { + // Validate the codecs early to surface help text before connecting. + { + let codecs: Vec<_> = args.codecs.iter().map(String::as_str).collect(); if let Err(help) = client_codecs_capabilities(&codecs) { print!("{help}"); std::process::exit(0); } + } - let redirect_clipboard = self.properties.redirect_clipboard().unwrap_or(true); - builder = builder.with_clipboard(resolve_clipboard_type(self.clipboard_type, redirect_clipboard)); + if let Some(destination) = args.destination { + builder = builder.with_destination(destination); + } + if let Some(username) = args.username { + builder = builder.with_username(username); + } + if let Some(password) = args.password { + builder = builder.with_password(password); + } + if let Some(domain) = args.domain { + builder = builder.with_domain(domain); + } + if let Some(scale) = args.scale_desktop { + builder = builder.with_desktop_scale_factor(scale); + } + if let Some(width) = args.desktop_width { + builder = builder.with_desktop_width(width); + } + if let Some(height) = args.desktop_height { + builder = builder.with_desktop_height(height); + } + if let Some(color_depth) = args.color_depth { + builder = builder.with_color_depth(color_depth); + } + if args.no_credssp { + builder = builder.with_credssp(false); + } + if args.no_tls { + builder = builder.with_tls(false); + } + if args.no_server_pointer { + builder = builder.with_server_pointer(false); + } + if args.autologon { + builder = builder.with_autologon(true); + } + if args.no_compression { + builder = builder.with_compression(false); + } + if let Some(level) = args.compression_level { + builder = builder.with_compression_level(level); + } + if let Some(minutes) = args.prevent_session_lock { + builder = builder.with_fake_events_interval(Duration::from_secs(u64::from(minutes) * 60)); + } - prompt_missing(builder) + // Transport overrides: RDCleanPath takes precedence over Gateway. + if let Some(url) = args.rdcleanpath_url { + builder = builder.with_transport(TransportKind::RDCleanPath { url }); + + if let Some(token) = args.rdcleanpath_token { + builder = builder.with_rdcleanpath_token(token); + } + } else if let Some(endpoint) = args.gw_endpoint { + builder = builder.with_transport(TransportKind::Gateway { endpoint }); + + if let Some(username) = args.gw_user { + builder = builder.with_gateway_username(username); + } + if let Some(password) = args.gw_pass { + builder = builder.with_gateway_password(password); + } + } + + builder = builder.with_clipboard(resolve_clipboard_type(args.clipboard_type, redirect_clipboard)); + + // CLI-only knobs that are not representable as `.rdp` properties. + // TODO/FIXME: Some of these, we may want to add support for storing in .rdp files (e.g.: IME file name can be reasonably seen as a connection option) + builder = builder + .with_keyboard_type(args.keyboard_type.into_pdu()) + .with_keyboard_subtype(args.keyboard_subtype) + .with_keyboard_functional_keys_count(args.keyboard_functional_keys_count) + .with_ime_file_name(args.ime_file_name) + .with_dig_product_id(args.dig_product_id) + .with_codecs(args.codecs); + + for proxy in args.dvc_proxy { + builder = builder.with_dvc_pipe_proxy(proxy); } + + #[cfg(windows)] + for plugin in args.dvc_plugin { + builder = builder.with_dvc_plugin(plugin); + } + + builder } /// Resolve the remaining [`MissingField`]s by prompting for credentials/addresses and deriving the /// frontend-specific client identity, then build the [`Config`]. -fn prompt_missing(mut builder: ConfigBuilder) -> anyhow::Result { +/// +/// When `dump` is set, the resulting config is only used to observe the effective, secret-stripped +/// PropertySet (no session is started). Secret fields are stripped on `build()` regardless, so they +/// are filled with a placeholder instead of being prompted for. +fn prompt_missing(mut builder: ConfigBuilder, dump: bool) -> anyhow::Result { + // Stripped on `build()`, so any value works when only dumping the PropertySet. + const DUMP_SECRET_PLACEHOLDER: &str = ""; + for field in builder.missing() { builder = match field { MissingField::ServerAddress => { @@ -462,6 +415,7 @@ fn prompt_missing(mut builder: ConfigBuilder) -> anyhow::Result { let username = inquire::Text::new("Username:").prompt().context("Username prompt")?; builder.with_username(username) } + MissingField::Password if dump => builder.with_password(DUMP_SECRET_PLACEHOLDER), MissingField::Password => { let password = inquire::Password::new("Password:") .without_confirmation() @@ -475,6 +429,7 @@ fn prompt_missing(mut builder: ConfigBuilder) -> anyhow::Result { .context("Gateway username prompt")?; builder.with_gateway_username(username) } + MissingField::GatewayPassword if dump => builder.with_gateway_password(DUMP_SECRET_PLACEHOLDER), MissingField::GatewayPassword => { let password = inquire::Password::new("Gateway password:") .without_confirmation() @@ -482,6 +437,13 @@ fn prompt_missing(mut builder: ConfigBuilder) -> anyhow::Result { .context("Gateway password prompt")?; builder.with_gateway_password(password) } + MissingField::RDCleanPathToken if dump => builder.with_rdcleanpath_token(DUMP_SECRET_PLACEHOLDER), + MissingField::RDCleanPathToken => { + let token = inquire::Text::new("RDCleanPath token:") + .prompt() + .context("RDCleanPath token prompt")?; + builder.with_rdcleanpath_token(token) + } // Frontend-derived identity: never prompted. MissingField::ClientBuild => builder.with_client_build(client_build()), MissingField::ClientDir => { @@ -520,7 +482,7 @@ fn current_platform() -> MajorPlatformType { } pub fn parse_config() -> anyhow::Result { - PartialConfig::parse_args()?.into_config() + ViewerConfig::parse_args()?.into_config() } pub fn parse_config_from(args: I) -> anyhow::Result @@ -528,7 +490,7 @@ where I: IntoIterator, T: Into + Clone, { - PartialConfig::parse_from(args)?.into_config() + ViewerConfig::parse_from(args)?.into_config() } fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> ResolvedClipboardType { diff --git a/crates/ironrdp-viewer/src/lib.rs b/crates/ironrdp-viewer/src/lib.rs index 9d1f6d63fb..2939e5d7f4 100644 --- a/crates/ironrdp-viewer/src/lib.rs +++ b/crates/ironrdp-viewer/src/lib.rs @@ -10,4 +10,4 @@ #![allow(clippy::cast_sign_loss)] pub mod app; -pub mod config; +pub mod cli; diff --git a/crates/ironrdp-viewer/src/main.rs b/crates/ironrdp-viewer/src/main.rs index 9b579486a3..797d327a12 100644 --- a/crates/ironrdp-viewer/src/main.rs +++ b/crates/ironrdp-viewer/src/main.rs @@ -3,7 +3,7 @@ use anyhow::Context as _; use ironrdp::client::rdp::{RdpClient, RdpOutputEvent}; use ironrdp_viewer::app::App; -use ironrdp_viewer::config::PartialConfig; +use ironrdp_viewer::cli::ViewerConfig; use tokio::runtime; use tokio::sync::mpsc; use tracing::debug; @@ -11,17 +11,19 @@ use winit::dpi::PhysicalSize; use winit::event_loop::EventLoop; fn main() -> anyhow::Result<()> { - let partial = PartialConfig::parse_args().context("CLI arguments parsing")?; + let cli = ViewerConfig::parse_args().context("CLI arguments parsing")?; - if let Some(dump_path) = &partial.dump_rdp { - let content = ironrdp_rdpfile::write(&partial.properties); - std::fs::write(dump_path, &content).with_context(|| format!("failed to write {}", dump_path.display()))?; - return Ok(()); - } + setup_logging(cli.log_file()).context("unable to initialize logging")?; - setup_logging(partial.log_file.as_deref()).context("unable to initialize logging")?; + let dump_rdp = cli.dump_rdp().map(ToOwned::to_owned); + let config = cli.into_config().context("configuration")?; - let config = partial.into_config().context("configuration")?; + if let Some(dump_path) = dump_rdp { + // Dump the effective, secret-stripped PropertySet observed from the built configuration. + let content = ironrdp_rdpfile::write(config.properties()); + std::fs::write(&dump_path, &content).with_context(|| format!("failed to write {}", dump_path.display()))?; + return Ok(()); + } debug!("Initialize App"); let event_loop = EventLoop::::with_user_event().build()?; From d6990d81a17e8349e52768ad8a82f673b1e1462d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:21:25 +0900 Subject: [PATCH 300/325] fix(error): propagate caller location through error constructor helpers (#1392) The error constructor helpers in several crates wrap the #[track_caller] ironrdp_error::Error::new, but were not themselves marked #[track_caller]. As a result, the captured location pointed at the helper body instead of the real call site, giving misleading "@ file:line" info in error reports. --- crates/ironrdp-connector/src/lib.rs | 5 +++++ crates/ironrdp-core/src/decode.rs | 7 +++++++ crates/ironrdp-core/src/encode.rs | 7 +++++++ crates/ironrdp-mstsgu/src/lib.rs | 1 + crates/ironrdp-pdu/src/lib.rs | 2 ++ crates/ironrdp-session/src/lib.rs | 6 ++++++ 6 files changed, 28 insertions(+) diff --git a/crates/ironrdp-connector/src/lib.rs b/crates/ironrdp-connector/src/lib.rs index d81e144100..f78420078e 100644 --- a/crates/ironrdp-connector/src/lib.rs +++ b/crates/ironrdp-connector/src/lib.rs @@ -393,22 +393,27 @@ pub trait ConnectorErrorExt { } impl ConnectorErrorExt for ConnectorError { + #[track_caller] fn encode(error: ironrdp_core::EncodeError) -> Self { Self::new("encode error", ConnectorErrorKind::Encode(error)) } + #[track_caller] fn decode(error: ironrdp_core::DecodeError) -> Self { Self::new("decode error", ConnectorErrorKind::Decode(error)) } + #[track_caller] fn general(context: &'static str) -> Self { Self::new(context, ConnectorErrorKind::General) } + #[track_caller] fn reason(context: &'static str, reason: impl Into) -> Self { Self::new(context, ConnectorErrorKind::Reason(reason.into())) } + #[track_caller] fn custom(context: &'static str, e: E) -> Self where E: core::error::Error + Sync + Send + 'static, diff --git a/crates/ironrdp-core/src/decode.rs b/crates/ironrdp-core/src/decode.rs index 910c95ff0e..5d3a7200aa 100644 --- a/crates/ironrdp-core/src/decode.rs +++ b/crates/ironrdp-core/src/decode.rs @@ -98,24 +98,28 @@ impl fmt::Display for DecodeErrorKind { } impl NotEnoughBytesErr for DecodeError { + #[track_caller] fn not_enough_bytes(context: &'static str, received: usize, expected: usize) -> Self { Self::new(context, DecodeErrorKind::NotEnoughBytes { received, expected }) } } impl InvalidFieldErr for DecodeError { + #[track_caller] fn invalid_field(context: &'static str, field: &'static str, reason: &'static str) -> Self { Self::new(context, DecodeErrorKind::InvalidField { field, reason }) } } impl UnexpectedMessageTypeErr for DecodeError { + #[track_caller] fn unexpected_message_type(context: &'static str, got: u8) -> Self { Self::new(context, DecodeErrorKind::UnexpectedMessageType { got }) } } impl UnsupportedVersionErr for DecodeError { + #[track_caller] fn unsupported_version(context: &'static str, got: u8) -> Self { Self::new(context, DecodeErrorKind::UnsupportedVersion { got }) } @@ -123,16 +127,19 @@ impl UnsupportedVersionErr for DecodeError { impl UnsupportedValueErr for DecodeError { #[cfg(feature = "alloc")] + #[track_caller] fn unsupported_value(context: &'static str, name: &'static str, value: String) -> Self { Self::new(context, DecodeErrorKind::UnsupportedValue { name, value }) } #[cfg(not(feature = "alloc"))] + #[track_caller] fn unsupported_value(context: &'static str, name: &'static str) -> Self { Self::new(context, DecodeErrorKind::UnsupportedValue { name }) } } impl OtherErr for DecodeError { + #[track_caller] fn other(context: &'static str, description: &'static str) -> Self { Self::new(context, DecodeErrorKind::Other { description }) } diff --git a/crates/ironrdp-core/src/encode.rs b/crates/ironrdp-core/src/encode.rs index 75e4e256ed..a4d0cd7ee0 100644 --- a/crates/ironrdp-core/src/encode.rs +++ b/crates/ironrdp-core/src/encode.rs @@ -102,24 +102,28 @@ impl fmt::Display for EncodeErrorKind { } impl NotEnoughBytesErr for EncodeError { + #[track_caller] fn not_enough_bytes(context: &'static str, received: usize, expected: usize) -> Self { Self::new(context, EncodeErrorKind::NotEnoughBytes { received, expected }) } } impl InvalidFieldErr for EncodeError { + #[track_caller] fn invalid_field(context: &'static str, field: &'static str, reason: &'static str) -> Self { Self::new(context, EncodeErrorKind::InvalidField { field, reason }) } } impl UnexpectedMessageTypeErr for EncodeError { + #[track_caller] fn unexpected_message_type(context: &'static str, got: u8) -> Self { Self::new(context, EncodeErrorKind::UnexpectedMessageType { got }) } } impl UnsupportedVersionErr for EncodeError { + #[track_caller] fn unsupported_version(context: &'static str, got: u8) -> Self { Self::new(context, EncodeErrorKind::UnsupportedVersion { got }) } @@ -127,16 +131,19 @@ impl UnsupportedVersionErr for EncodeError { impl UnsupportedValueErr for EncodeError { #[cfg(feature = "alloc")] + #[track_caller] fn unsupported_value(context: &'static str, name: &'static str, value: String) -> Self { Self::new(context, EncodeErrorKind::UnsupportedValue { name, value }) } #[cfg(not(feature = "alloc"))] + #[track_caller] fn unsupported_value(context: &'static str, name: &'static str) -> Self { Self::new(context, EncodeErrorKind::UnsupportedValue { name }) } } impl OtherErr for EncodeError { + #[track_caller] fn other(context: &'static str, description: &'static str) -> Self { Self::new(context, EncodeErrorKind::Other { description }) } diff --git a/crates/ironrdp-mstsgu/src/lib.rs b/crates/ironrdp-mstsgu/src/lib.rs index 805dda69a4..522051d0a1 100644 --- a/crates/ironrdp-mstsgu/src/lib.rs +++ b/crates/ironrdp-mstsgu/src/lib.rs @@ -65,6 +65,7 @@ trait GwErrorExt { } impl GwErrorExt for ironrdp_error::Error { + #[track_caller] fn custom(context: &'static str, e: E) -> Self where E: core::error::Error + Sync + Send + 'static, diff --git a/crates/ironrdp-pdu/src/lib.rs b/crates/ironrdp-pdu/src/lib.rs index 8db507d67e..031a95fa18 100644 --- a/crates/ironrdp-pdu/src/lib.rs +++ b/crates/ironrdp-pdu/src/lib.rs @@ -50,10 +50,12 @@ pub trait PduErrorExt { } impl PduErrorExt for PduError { + #[track_caller] fn decode(context: &'static str, source: E) -> Self { Self::new(context, PduErrorKind::Decode).with_source(source) } + #[track_caller] fn encode(context: &'static str, source: E) -> Self { Self::new(context, PduErrorKind::Encode).with_source(source) } diff --git a/crates/ironrdp-session/src/lib.rs b/crates/ironrdp-session/src/lib.rs index 3259360607..4c944ebd38 100644 --- a/crates/ironrdp-session/src/lib.rs +++ b/crates/ironrdp-session/src/lib.rs @@ -71,26 +71,32 @@ pub trait SessionErrorExt { } impl SessionErrorExt for SessionError { + #[track_caller] fn pdu(error: ironrdp_pdu::PduError) -> Self { Self::new("payload error", SessionErrorKind::Pdu(error)) } + #[track_caller] fn encode(error: ironrdp_core::EncodeError) -> Self { Self::new("encode error", SessionErrorKind::Encode(error)) } + #[track_caller] fn decode(error: ironrdp_core::DecodeError) -> Self { Self::new("decode error", SessionErrorKind::Decode(error)) } + #[track_caller] fn general(context: &'static str) -> Self { Self::new(context, SessionErrorKind::General) } + #[track_caller] fn reason(context: &'static str, reason: impl Into) -> Self { Self::new(context, SessionErrorKind::Reason(reason.into())) } + #[track_caller] fn custom(context: &'static str, e: E) -> Self where E: core::error::Error + Sync + Send + 'static, From 1b752d282a11fc9bda5d4e051414e414b7eec50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:40:43 +0900 Subject: [PATCH 301/325] fix(propertyset): remove logging from PropertySet (#1393) The debug! logging in insert/remove/get stringified raw keys and values on every access, which exposed secrets such as ClearTextPassword, gateway_password and rdcleanpath_token in logs. Observed debugging value was low, so the logging is removed entirely rather than redacted, and the tracing dependency is dropped. --- Cargo.lock | 3 --- crates/ironrdp-propertyset/Cargo.toml | 3 --- crates/ironrdp-propertyset/src/lib.rs | 21 ++------------------- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5778761172..d2538cae87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2773,9 +2773,6 @@ version = "0.0.0" [[package]] name = "ironrdp-propertyset" version = "0.1.0" -dependencies = [ - "tracing", -] [[package]] name = "ironrdp-rdcleanpath" diff --git a/crates/ironrdp-propertyset/Cargo.toml b/crates/ironrdp-propertyset/Cargo.toml index a8a966fb7c..fa81e364eb 100644 --- a/crates/ironrdp-propertyset/Cargo.toml +++ b/crates/ironrdp-propertyset/Cargo.toml @@ -17,8 +17,5 @@ categories.workspace = true doctest = false test = false -[dependencies] -tracing = { version = "0.1", features = ["log"] } - [lints] workspace = true diff --git a/crates/ironrdp-propertyset/src/lib.rs b/crates/ironrdp-propertyset/src/lib.rs index 74e90e7368..54f77b6399 100644 --- a/crates/ironrdp-propertyset/src/lib.rs +++ b/crates/ironrdp-propertyset/src/lib.rs @@ -9,8 +9,6 @@ use alloc::collections::BTreeMap; use alloc::string::String; use core::fmt::{self, Display}; -use tracing::debug; - pub type Key = Cow<'static, str>; /// Key-value store for configuration keys. @@ -26,30 +24,15 @@ impl PropertySet { pub fn insert(&mut self, key: impl Into, value: impl Into) -> Option { let (key, value) = (key.into(), value.into()); - debug!("PropertySet::insert({key}, {value})"); self.inner.insert(key, value) } pub fn remove(&mut self, key: &str) -> Option { - let value = self.inner.remove(key); - - match &value { - Some(value) => debug!("PropertySet::remove({key}) = {value}"), - None => debug!("PropertySet::remove({key}) = None"), - } - - value + self.inner.remove(key) } pub fn get<'a, V: ExtractFrom<&'a Value>>(&'a self, key: &str) -> Option { - let value = self.inner.get(key); - - match &value { - Some(value) => debug!("PropertySet::get({key}) = {value}"), - None => debug!("PropertySet::get({key}) = None"), - } - - value.and_then(|val| V::extract_from(val, private::Token)) + self.inner.get(key).and_then(|val| V::extract_from(val, private::Token)) } pub fn iter(&self) -> impl Iterator { From 2046639870530458479cdacec0b2eb056ee10edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:52:26 +0900 Subject: [PATCH 302/325] feat(agent): introduce ironrdp-agent crate (#1339) Add a CLI-driven, daemon-backed RDP client designed for programmatic (e.g. LLM) consumption. A single binary plays two roles: a long-lived daemon that owns the ironrdp-client engine and one RDP session, and a short-lived CLI that drives it over a local IPC transport (Unix domain socket / Windows named pipe). Highlights: - Binary, length-delimited IPC protocol using ironrdp-core's Encode/Decode traits (no JSON). Connection config travels as a binary-encoded PropertySet inside a strictly-typed Request::Connect; runtime input/query operations are strictly-typed messages. - Secrets never reach the IPC reader: ConfigBuilder::build strips every ironrdp_cfg::is_secret_key property, and the daemon seeds its live bag from the post-build config, so dumps/status/logs cannot leak them. - Operator overlay: daemon-start --overlay FILE preloads a .rdp file applied on top of every connect (overlay wins) to provision any setting out of band, credentials in particular. Status reports credentials_loaded so a caller knows whether it must supply a password. - Separate logging: the daemon's own logs go to stderr (default INFO, IRONRDP_LOG), while each RDP session's logs are captured into a small queryable ring buffer (default DEBUG) via a thread-local subscriber, refinable per-connect with --log-directive for troubleshooting. - --help-agent prints a structured, LLM-friendly operation guide. --- Cargo.lock | 25 + crates/ironrdp-agent/Cargo.toml | 64 ++ crates/ironrdp-agent/README.md | 61 ++ crates/ironrdp-agent/src/cli.rs | 423 ++++++++++ crates/ironrdp-agent/src/daemon.rs | 546 ++++++++++++ crates/ironrdp-agent/src/help.rs | 77 ++ crates/ironrdp-agent/src/ipc.rs | 778 ++++++++++++++++++ crates/ironrdp-agent/src/lib.rs | 27 + crates/ironrdp-agent/src/logbuf.rs | 147 ++++ crates/ironrdp-agent/src/main.rs | 10 + crates/ironrdp-agent/src/transport.rs | 195 +++++ crates/ironrdp-agent/src/wire/mod.rs | 160 ++++ crates/ironrdp-agent/src/wire/propertyset.rs | 83 ++ crates/ironrdp-client/src/config.rs | 13 +- crates/ironrdp-testsuite-extra/Cargo.toml | 4 + crates/ironrdp-testsuite-extra/tests/agent.rs | 176 ++++ crates/ironrdp-testsuite-extra/tests/main.rs | 1 + 17 files changed, 2789 insertions(+), 1 deletion(-) create mode 100644 crates/ironrdp-agent/Cargo.toml create mode 100644 crates/ironrdp-agent/README.md create mode 100644 crates/ironrdp-agent/src/cli.rs create mode 100644 crates/ironrdp-agent/src/daemon.rs create mode 100644 crates/ironrdp-agent/src/help.rs create mode 100644 crates/ironrdp-agent/src/ipc.rs create mode 100644 crates/ironrdp-agent/src/lib.rs create mode 100644 crates/ironrdp-agent/src/logbuf.rs create mode 100644 crates/ironrdp-agent/src/main.rs create mode 100644 crates/ironrdp-agent/src/transport.rs create mode 100644 crates/ironrdp-agent/src/wire/mod.rs create mode 100644 crates/ironrdp-agent/src/wire/propertyset.rs create mode 100644 crates/ironrdp-testsuite-extra/tests/agent.rs diff --git a/Cargo.lock b/Cargo.lock index d2538cae87..6f52aa85b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2439,6 +2439,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "ironrdp-agent" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "ironrdp-cfg", + "ironrdp-client", + "ironrdp-core", + "ironrdp-input", + "ironrdp-pdu", + "ironrdp-propertyset", + "ironrdp-rdpfile", + "libc", + "png", + "tokio", + "tracing", + "tracing-subscriber", + "whoami", +] + [[package]] name = "ironrdp-ainput" version = "0.7.0" @@ -2969,8 +2990,12 @@ dependencies = [ "anyhow", "async-trait", "ironrdp", + "ironrdp-agent", "ironrdp-async", "ironrdp-client", + "ironrdp-core", + "ironrdp-input", + "ironrdp-propertyset", "ironrdp-tls", "ironrdp-tokio", "ironrdp-viewer", diff --git a/crates/ironrdp-agent/Cargo.toml b/crates/ironrdp-agent/Cargo.toml new file mode 100644 index 0000000000..641077870b --- /dev/null +++ b/crates/ironrdp-agent/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "ironrdp-agent" +version = "0.0.0" +readme = "README.md" +description = "CLI-driven, daemon-backed agentic RDP client suitable for LLM consumption" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +# Not publishing for now. +publish = false + +[lib] +doctest = false +test = false + +[[bin]] +name = "ironrdp-agent" +path = "src/main.rs" +test = false + +[features] +# Exposes otherwise-internal modules (e.g. the wire codec helpers) for unit testing from the +# workspace test suite. Hidden from docs; not intended for downstream use. +internal = [] + +[dependencies] +# RDP client engine: only the TLS backend is mandated +ironrdp-client = { path = "../ironrdp-client", features = ["rustls"] } + +# Configuration model and codecs +ironrdp-core = { path = "../ironrdp-core", features = ["alloc"] } +ironrdp-pdu = { path = "../ironrdp-pdu" } +ironrdp-propertyset = { path = "../ironrdp-propertyset" } +ironrdp-cfg = { path = "../ironrdp-cfg" } +ironrdp-rdpfile = { path = "../ironrdp-rdpfile" } +ironrdp-input = { path = "../ironrdp-input" } + +# Async runtime and IPC transport +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time", "signal"] } + +# CLI +clap = { version = "4.6", features = ["derive", "cargo"] } + +# Logging (ring-buffer tracing layer) +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# PNG encoding for screenshots +png = "0.18" + +# Utils +anyhow = "1" +whoami = "2.1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[lints] +workspace = true diff --git a/crates/ironrdp-agent/README.md b/crates/ironrdp-agent/README.md new file mode 100644 index 0000000000..e217d0d9f1 --- /dev/null +++ b/crates/ironrdp-agent/README.md @@ -0,0 +1,61 @@ +# IronRDP Agent + +A CLI-driven, daemon-backed RDP client designed for programmatic (e.g. LLM) consumption. + +The single `ironrdp-agent` binary bundles two roles: + +- **Daemon** (`ironrdp-agent daemon-start`): a long-lived, foreground process that owns the + [`ironrdp-client`] engine and one RDP session. It stays alive across many CLI invocations and + serves requests over a local IPC transport (a Unix domain socket on Unix, a named pipe on + Windows). +- **CLI** (`ironrdp-agent …`): a short-lived invocation that opens the IPC endpoint, sends a + single request, prints the response, and exits. + +Run `ironrdp-agent --help-agent` for a structured, machine-readable description of every operation. + +## Wire format + +Messages are encoded with [`ironrdp-core`]'s `Encode`/`Decode` traits, length-delimited with a +little-endian `u32` byte-count prefix. There is no JSON anywhere. Both ends are the same binary at +the same version, so the format carries no version byte. + +Connection configuration travels as a binary-encoded [`PropertySet`][`ironrdp-propertyset`] inside a +strictly-typed `Request::Connect`. Runtime operations (mouse, keyboard, status, logs, …) are +strictly-typed messages. `Request::Screenshot` returns the most recent frame as PNG bytes (with the +mouse cursor composited in — the agent enables software pointer rendering), which the CLI writes to +disk. + +## Secrets + +The daemon never exposes secrets to the IPC reader. `ConfigBuilder::build` strips every +`ironrdp_cfg::is_secret_key` property (`ClearTextPassword`, `GatewayPassword`, the RDCleanPath +token, …) before producing the `Config`, and the daemon seeds its live property bag from that +post-build configuration. Secrets therefore never reach the live bag, so property dumps, status, +and logs cannot leak them — no separate redaction pass is needed. + +## Preloaded overlay + +An operator can preconfigure any settings — credentials in particular — without handing them to the +IPC caller. Pass an overlay [`PropertySet`][`ironrdp-propertyset`] to `daemon-start --overlay FILE`; +the daemon layers it on top of every `Request::Connect` before building the configuration (overlay +wins). When the overlay carries a secret (password/token), `Request::Status` reports +`credentials_loaded`, so a caller should check the status first to learn whether it still needs to +supply a password. + +## Logging + +Two logging concerns are kept separate: + +- **Daemon logging** is the daemon's own operational logging (IPC handling, lifecycle). It is the + global `tracing` subscriber: a compact formatter writing to stderr, defaulting to `info` and + tunable with the `IRONRDP_LOG` environment variable, mirroring [`ironrdp-viewer`]. +- **RDP session logging** is captured into a small, queryable in-memory ring buffer (read via + `Request::QueryLogs`) instead of the terminal. It is installed as a thread-local subscriber for + the session thread only (`tracing::dispatcher::with_default`), so it never becomes the global + subscriber. It defaults to `debug`; a per-`Connect` `log_directive` (e.g. `ironrdp_connector=trace`) + refines the filter to troubleshoot IronRDP itself. + +[`ironrdp-client`]: ../ironrdp-client +[`ironrdp-core`]: ../ironrdp-core +[`ironrdp-propertyset`]: ../ironrdp-propertyset +[`ironrdp-viewer`]: ../ironrdp-viewer diff --git a/crates/ironrdp-agent/src/cli.rs b/crates/ironrdp-agent/src/cli.rs new file mode 100644 index 0000000000..7b89e352be --- /dev/null +++ b/crates/ironrdp-agent/src/cli.rs @@ -0,0 +1,423 @@ +//! The short-lived CLI: parse arguments, build a request (merging a `.rdp` file with overrides for +//! `connect`), send it to the daemon, and print the response. +//! +//! The CLI operates purely at the [`PropertySet`] level for connection config — it never calls +//! typed `ConfigBuilder` setters. + +#![allow(clippy::print_stdout, clippy::print_stderr)] + +use std::path::{Path, PathBuf}; + +use anyhow::Context as _; +use clap::{Args, CommandFactory as _, Parser, Subcommand, ValueEnum}; +use ironrdp_cfg::{PropertySetExt as _, TargetAddr}; +use ironrdp_input::MouseButton; +use ironrdp_propertyset::PropertySet; + +use crate::ipc::{KeyFilter, Payload, PropValue, Request, Response}; +use crate::transport::{self, Endpoint}; + +/// IronRDP agent: a CLI-driven, daemon-backed RDP client. +#[derive(Parser, Debug)] +#[command(name = "ironrdp-agent", version, about, long_about = None)] +pub struct Cli { + /// Print a structured, LLM-friendly guide to every operation and exit. + #[arg(long, global = true)] + help_agent: bool, + + /// Override the IPC endpoint (defaults to the per-user socket/pipe). + #[arg(long, global = true)] + endpoint: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run the long-lived daemon in the foreground (owns the RDP session). + DaemonStart(DaemonArgs), + /// Open an RDP session from a .rdp file and/or CLI overrides. + Connect(ConnectArgs), + /// Tear down the current RDP session (the daemon keeps running). + Disconnect, + /// Report the current session status. + Status, + /// Query the live session properties. + QueryProps(QueryPropsArgs), + /// Print the RDP session's captured log lines (from the daemon's in-memory ring buffer). + QueryLogs(QueryLogsArgs), + /// Capture the current frame (cursor included) as a PNG written to disk. + Screenshot(ScreenshotArgs), + /// Move the mouse pointer to an absolute position. + MouseMove { + #[arg(long)] + x: u16, + #[arg(long)] + y: u16, + }, + /// Press or release a mouse button. + MouseButton { + #[arg(long, value_enum)] + button: CliMouseButton, + #[arg(long, action = clap::ArgAction::Set)] + pressed: bool, + }, + /// Rotate the mouse wheel (negative delta scrolls down/left). + Wheel { + #[arg(long, allow_hyphen_values = true)] + delta: i16, + #[arg(long)] + horizontal: bool, + }, + /// Press or release a key identified by its RDP scancode. + KeyScancode { + #[arg(long, value_parser = parse_scancode)] + scancode: u16, + #[arg(long, action = clap::ArgAction::Set)] + pressed: bool, + }, + /// Press or release a key identified by a Unicode character. + KeyUnicode { + #[arg(long = "char")] + character: char, + #[arg(long, action = clap::ArgAction::Set)] + pressed: bool, + }, +} + +#[derive(Args, Debug)] +struct DaemonArgs { + /// Path to a .rdp file whose properties are preloaded as an overlay applied to every `connect` + /// (overlay wins). Use this to provision any setting out of band — credentials in particular + /// (e.g. `ClearTextPassword`), so a caller never needs to supply them; `status` then reports + /// `credentials loaded: true`. + #[arg(long)] + overlay: Option, +} + +#[derive(Args, Debug)] +struct ConnectArgs { + /// Path to a .rdp file to read the base configuration from. + #[arg(long)] + rdp_file: Option, + /// RDP server address (host[:port]). Overrides the .rdp file. + #[arg(long)] + server: Option, + /// RDP account user name. Overrides the .rdp file. + #[arg(short, long)] + username: Option, + /// RDP account password. Overrides the .rdp file. + #[arg(short, long)] + password: Option, + /// RDP account domain. Overrides the .rdp file. + #[arg(short, long)] + domain: Option, + /// Tracing filter directive applied to this session's log capture (e.g. + /// `ironrdp_connector=trace`), layered on top of the default `debug` level. Use it to raise + /// verbosity up-front when troubleshooting a connection. + #[arg(long)] + log_directive: Option, +} + +#[derive(Args, Debug)] +struct QueryPropsArgs { + /// Only show keys containing this substring (case-insensitive). + #[arg(long, conflicts_with = "prefix")] + filter: Option, + /// Only show keys starting with this prefix (case-insensitive). + #[arg(long)] + prefix: Option, +} + +#[derive(Args, Debug)] +struct QueryLogsArgs { + /// Only show lines containing this substring. + #[arg(long)] + substring: Option, + /// Only show the last N retained lines. + #[arg(long)] + last: Option, +} + +#[derive(Args, Debug)] +struct ScreenshotArgs { + /// Destination PNG path (defaults to `screenshot.png` in the current directory). + path: Option, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum CliMouseButton { + Left, + Middle, + Right, + X1, + X2, +} + +impl CliMouseButton { + fn into_button(self) -> MouseButton { + match self { + Self::Left => MouseButton::Left, + Self::Middle => MouseButton::Middle, + Self::Right => MouseButton::Right, + Self::X1 => MouseButton::X1, + Self::X2 => MouseButton::X2, + } + } +} + +/// Parses an RDP scancode in decimal or `0x`-prefixed hexadecimal. +fn parse_scancode(input: &str) -> Result { + if let Some(hex) = input.strip_prefix("0x").or_else(|| input.strip_prefix("0X")) { + u16::from_str_radix(hex, 16) + } else { + input.parse() + } +} + +/// Entry point shared by the binary: dispatches the parsed [`Cli`]. +pub async fn run(cli: Cli) -> anyhow::Result<()> { + if cli.help_agent { + print!("{}", crate::help::AGENT_GUIDE); + return Ok(()); + } + + let endpoint = endpoint_from_arg(cli.endpoint); + + let Some(command) = cli.command else { + let _ = Cli::command().print_help(); + println!(); + return Ok(()); + }; + + let request = match command { + Command::DaemonStart(args) => { + let overlay = load_overlay(args.overlay.as_deref())?; + return crate::daemon::run(endpoint, overlay).await; + } + Command::Connect(args) => build_connect_request(args)?, + Command::Disconnect => Request::Disconnect, + Command::Status => Request::Status, + Command::QueryProps(args) => Request::QueryProps { + filter: args + .filter + .map(KeyFilter::Substring) + .or_else(|| args.prefix.map(KeyFilter::Prefix)), + }, + Command::QueryLogs(args) => Request::QueryLogs { + substring: args.substring, + last: args.last, + }, + Command::Screenshot(args) => { + let response = transport::send_request(&endpoint, &Request::Screenshot).await?; + let payload = match response { + Response::Ok(payload) => payload, + Response::Err(message) => anyhow::bail!("{message}"), + }; + let Payload::Screenshot { width, height, png } = payload else { + anyhow::bail!("unexpected response to screenshot request"); + }; + let path = args.path.unwrap_or_else(|| PathBuf::from("screenshot.png")); + return write_screenshot(width, height, &png, &path); + } + Command::MouseMove { x, y } => Request::MouseMove { x, y }, + Command::MouseButton { button, pressed } => Request::MouseButton { + button: button.into_button(), + pressed, + }, + Command::Wheel { delta, horizontal } => Request::Wheel { delta, horizontal }, + Command::KeyScancode { scancode, pressed } => Request::KeyScancode { scancode, pressed }, + Command::KeyUnicode { character, pressed } => Request::KeyUnicode { ch: character, pressed }, + }; + + let response = transport::send_request(&endpoint, &request).await?; + print_response(response) +} + +/// Loads an operator-provided overlay [`PropertySet`] from an optional `.rdp` file. Returns an +/// empty set when no path is given. +fn load_overlay(path: Option<&Path>) -> anyhow::Result { + let mut properties = PropertySet::new(); + if let Some(path) = path { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &text) { + for error in &errors { + eprintln!("warning: skipped entry in {}: {error}", path.display()); + } + } + } + Ok(properties) +} + +/// Builds a `Connect` request by merging an optional `.rdp` file with CLI overrides into one +/// [`PropertySet`]. Configuration validation happens daemon-side (via +/// `ConfigBuilder::from_property_set`); this only parses and merges the inputs. +fn build_connect_request(args: ConnectArgs) -> anyhow::Result { + let mut properties = PropertySet::new(); + + if let Some(path) = &args.rdp_file { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &text) { + for error in &errors { + eprintln!("warning: skipped entry in {}: {error}", path.display()); + } + } + } + + // CLI overrides win. + if let Some(server) = args.server { + let address: TargetAddr = server + .parse() + .with_context(|| format!("invalid server address: {server}"))?; + properties.set_full_address(&address); + } + if let Some(username) = args.username { + properties.set_username(username); + } + if let Some(password) = args.password { + properties.set_clear_text_password(password); + } + if let Some(domain) = args.domain { + properties.set_domain(domain); + } + + Ok(Request::Connect { + properties, + log_directive: args.log_directive, + }) +} + +fn print_response(response: Response) -> anyhow::Result<()> { + match response { + Response::Ok(payload) => { + print_payload(payload); + Ok(()) + } + Response::Err(message) => anyhow::bail!("{message}"), + } +} + +fn print_payload(payload: Payload) { + match payload { + Payload::Empty => println!("ok"), + Payload::Status(status) => { + println!("state: {:?}", status.state); + if let Some(destination) = status.destination { + println!("destination: {destination}"); + } + if let (Some(width), Some(height)) = (status.width, status.height) { + println!("resolution: {width}x{height}"); + } + if let Some(message) = status.message { + println!("detail: {message}"); + } + println!("credentials loaded: {}", status.credentials_loaded); + } + Payload::Properties(dump) => { + for entry in dump.entries { + let value = match entry.value { + PropValue::Int(value) => value.to_string(), + PropValue::Str(value) => value, + }; + // Descriptions are derived locally from the key: they are a static function of the + // property name, so there is no reason to carry them over the wire. + match property_description(&entry.key) { + Some(description) => println!("{} = {value} # {description}", entry.key), + None => println!("{} = {value}", entry.key), + } + } + } + Payload::Logs(lines) => { + for line in lines { + println!("{line}"); + } + } + // Screenshots are handled out-of-band by `write_screenshot`, never printed. + Payload::Screenshot { width, height, .. } => println!("frame {width}x{height}"), + } +} + +/// Writes screenshot PNG bytes to disk, defaulting to `screenshot.png`. +fn write_screenshot(width: u16, height: u16, png: &[u8], path: &Path) -> anyhow::Result<()> { + std::fs::write(path, png).with_context(|| format!("write {}", path.display()))?; + println!("wrote {} ({width}x{height}, {} bytes)", path.display(), png.len()); + Ok(()) +} + +#[cfg(unix)] +fn endpoint_from_arg(arg: Option) -> Endpoint { + match arg { + Some(value) => Endpoint(PathBuf::from(value)), + None => transport::default_endpoint(), + } +} + +#[cfg(windows)] +fn endpoint_from_arg(arg: Option) -> Endpoint { + match arg { + Some(value) => Endpoint(value), + None => transport::default_endpoint(), + } +} + +/// Short, LLM-facing descriptions for the configuration keys recognized by [`ironrdp_cfg`], derived +/// locally from the key name when printing a dump (kept out of the wire protocol on purpose). +/// +/// Keys are the canonical lowercase `.rdp` names. Secret keys are listed for completeness even +/// though `ConfigBuilder::build` strips them before a session starts, so they never appear in a +/// dump. +fn property_description(key: &str) -> Option<&'static str> { + // PropertySet keys are case-sensitive and ironrdp-cfg mixes casings (e.g. `ClearTextPassword`), + // so normalize to lowercase to match the canonical lowercase arms below. + let description = match key.to_ascii_lowercase().as_str() { + // ── Standard .rdp keys ────────────────────────────────────────────── + "full address" => "RDP server address as host[:port]", + "alternate full address" => "fallback RDP server address (host[:port]) tried if 'full address' fails", + "server port" => "RDP server TCP port (default 3389)", + "username" => "RDP account user name", + "domain" => "RDP account domain", + "cleartextpassword" => "plaintext RDP account password (secret)", + "desktopwidth" => "requested remote desktop width in pixels", + "desktopheight" => "requested remote desktop height in pixels", + "desktopscalefactor" => "remote desktop DPI scale factor, in percent (e.g. 100, 150)", + "compression" => "enable bulk data compression (0/1)", + "audiomode" => "remote audio mode (0 = play on client, 1 = play on server, 2 = disabled)", + "redirectclipboard" => "enable clipboard redirection (0/1)", + "enablecredsspsupport" => "enable CredSSP/NLA authentication (0/1)", + "alternate shell" => "program to launch on connect instead of the desktop shell", + "shell working directory" => "working directory for the alternate shell or RemoteApp program", + "remoteapplicationname" => "RemoteApp display name", + "remoteapplicationprogram" => "RemoteApp program path to launch", + // ── RD gateway ────────────────────────────────────────────────────── + "gatewayhostname" => "RD gateway host name", + "gatewayusername" => "RD gateway user name", + "gatewaypassword" => "RD gateway password (secret)", + "gatewayusagemethod" => { + "when to use the RD gateway (0 = direct, 1 = always, 2 = detect, 3 = default, 4 = direct, bypass for local)" + } + "gatewaycredentialssource" => { + "RD gateway credential source (0 = server, 1 = user, 2 = profile, 3 = prompt, 4 = smart card, 5 = logon)" + } + // ── Kerberos ──────────────────────────────────────────────────────── + "kdcproxyname" => "Kerberos KDC proxy name", + "kdcproxyurl" => "Kerberos KDC proxy URL", + // ── IronRDP extensions (ironrdp_ prefix) ──────────────────────────── + "ironrdp_autologon" => "attempt automatic logon with the supplied credentials (0/1)", + "ironrdp_colordepth" => "color depth in bits per pixel (e.g. 16 or 32)", + "ironrdp_compressionlevel" => "bulk compression level", + "ironrdp_dvcpipeproxy" => "DVC pipe proxy specs, comma-separated 'channel=pipe' pairs", + "ironrdp_dvcplugin" => "DVC plugin library paths, comma-separated", + "ironrdp_qoi" => "enable the QOI graphics codec (0/1)", + "ironrdp_qoiz" => "enable the QOIZ (compressed QOI) graphics codec (0/1)", + "ironrdp_rdpdr" => "enable the RDPDR device-redirection channel (0/1)", + "ironrdp_smartcard" => "enable smart-card device redirection (0/1)", + "ironrdp_tls" => "use plain TLS security instead of CredSSP/Hybrid (0/1)", + "ironrdp_fakeeventsinterval" => "interval in minutes between synthetic keep-alive input events", + "ironrdp_rdcleanpathtoken" => "RDCleanPath authentication token (secret)", + "ironrdp_rdcleanpathurl" => "RDCleanPath proxy URL", + "ironrdp_serverpointer" => "render the server-side pointer instead of a client-drawn pointer (0/1)", + _ => return None, + }; + Some(description) +} diff --git a/crates/ironrdp-agent/src/daemon.rs b/crates/ironrdp-agent/src/daemon.rs new file mode 100644 index 0000000000..dc5b869310 --- /dev/null +++ b/crates/ironrdp-agent/src/daemon.rs @@ -0,0 +1,546 @@ +//! The long-lived daemon: owns the [`RdpClient`] engine and one RDP session, and serves IPC +//! requests until shut down. +//! +//! One daemon serves one RDP session (multi-session is out of scope for V1). It is started +//! explicitly with `daemon-start` and runs in the foreground; the caller is expected to background +//! it. On a clean shutdown the Unix socket file is removed (see [`crate::transport`]). + +use std::sync::{Arc, Mutex}; + +use anyhow::Context as _; +use ironrdp_client::config::{ConfigBuilder, MissingField}; +use ironrdp_client::rdp::{RdpClient, RdpInputEvent, RdpOutputEvent}; +use ironrdp_input::{Database, MousePosition, Operation, Scancode, WheelRotations}; +use ironrdp_pdu::rdp::capability_sets::MajorPlatformType; +use ironrdp_propertyset::{PropertySet, Value}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::mpsc; +use tracing::{debug, error, info, trace, warn}; + +use crate::ipc::{ + ConnState, KeyFilter, Payload, PropValue, PropertyDump, PropertyEntry, Request, Response, StatusInfo, +}; +use crate::logbuf::{self, LogBuffer}; +use crate::transport::{Endpoint, Listener, read_message, write_message}; + +/// Binds the IPC endpoint and serves requests until a shutdown signal is received. +/// +/// `overlay` is an operator-provided [`PropertySet`] layered on top of every `Connect` request +/// (overlay wins), so any setting — credentials in particular — can be preconfigured without the +/// caller ever supplying it. Pass an empty set when no overlay is desired. +pub async fn run(endpoint: Endpoint, overlay: PropertySet) -> anyhow::Result<()> { + // On Unix a leftover socket file would make `bind` fail; clear it if no daemon is alive. + #[cfg(unix)] + if endpoint.0.exists() { + if crate::transport::connect(&endpoint).await.is_ok() { + anyhow::bail!("a daemon already appears to be running at {endpoint}"); + } + // No daemon answered, so the path is a stale socket we can reclaim. Guard against deleting + // an unrelated regular file (or following a symlink) when `--endpoint` points elsewhere: + // inspect the path itself and only remove genuine sockets. + use std::os::unix::fs::FileTypeExt as _; + let metadata = + std::fs::symlink_metadata(&endpoint.0).with_context(|| format!("stat IPC endpoint {endpoint}"))?; + if !metadata.file_type().is_socket() { + anyhow::bail!("refusing to remove {endpoint}: path exists and is not a socket"); + } + std::fs::remove_file(&endpoint.0).with_context(|| format!("remove stale socket {endpoint}"))?; + } + + init_daemon_logging(); + let logs = LogBuffer::new(); + + let mut listener = Listener::bind(&endpoint).with_context(|| format!("bind IPC endpoint {endpoint}"))?; + let daemon = Daemon::new(logs, overlay); + + info!(%endpoint, "Daemon listening"); + + loop { + tokio::select! { + result = listener.accept() => { + let stream = result.context("accept IPC connection")?; + if let Err(error) = handle_connection(stream, &daemon).await { + debug!(error = format!("{error:#}"), "IPC connection error"); + } + } + _ = tokio::signal::ctrl_c() => { + info!("Received shutdown signal, stopping"); + break; + } + } + } + + Ok(()) +} + +async fn handle_connection(mut stream: S, daemon: &Daemon) -> anyhow::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let request: Request = read_message(&mut stream).await?; + trace!(?request, "Handling IPC request"); + let response = daemon.handle(request); + trace!(ok = response.is_ok(), "Replying to IPC request"); + write_message(&mut stream, &response).await?; + Ok(()) +} + +/// The daemon's mutable state: the (single) current session, plus the shared log buffer. +struct Daemon { + state: Mutex>, + logs: Arc, + /// Operator-provided overlay layered on top of every `Connect` (overlay wins). Holds any + /// preconfigured settings, credentials in particular. + overlay: PropertySet, + /// Whether [`Self::overlay`] contributes any secret (password/token) value, i.e. whether the + /// caller can omit credentials of its own. + credentials_loaded: bool, +} + +/// Per-session state owned by the request handler. +struct Session { + input_tx: mpsc::UnboundedSender, + input_db: Database, + destination: String, + live: Arc>, +} + +/// Per-session state shared with the output-consumer task. +struct Live { + /// Live property bag, seeded from `Config::properties` and updated on (re)negotiation. + properties: PropertySet, + state: ConnState, + error: Option, + /// Most recent frame (with the cursor already composited in by the session). Replaced on every + /// graphics update; `None` until the first frame arrives. + frame: Option, +} + +/// A decoded frame retained for screenshots. `pixels` are `0x00RRGGBB` (`to_be_bytes()` yields +/// `[0, R, G, B]`), row-major, `width * height` entries, with the remote cursor blended in. +struct Frame { + width: u16, + height: u16, + pixels: Vec, +} + +impl Daemon { + fn new(logs: Arc, overlay: PropertySet) -> Self { + // Credentials are considered "loaded" when the overlay provides at least one secret value, + // which is what frees the caller from supplying a password. + let credentials_loaded = overlay.iter().any(|(key, _)| ironrdp_cfg::is_secret_key(key)); + Self { + state: Mutex::new(None), + logs, + overlay, + credentials_loaded, + } + } + + fn handle(&self, request: Request) -> Response { + match request { + Request::Connect { + properties, + log_directive, + } => self.connect(properties, log_directive), + Request::Disconnect => self.disconnect(), + Request::Status => self.status(), + Request::QueryProps { filter } => self.query_props(filter.as_ref()), + Request::QueryLogs { substring, last } => self.query_logs(substring.as_deref(), last), + Request::Screenshot => self.screenshot(), + Request::MouseMove { x, y } => self.input(Operation::MouseMove(MousePosition { x, y })), + Request::MouseButton { button, pressed } => self.input(if pressed { + Operation::MouseButtonPressed(button) + } else { + Operation::MouseButtonReleased(button) + }), + Request::Wheel { delta, horizontal } => self.input(Operation::WheelRotations(WheelRotations { + is_vertical: !horizontal, + rotation_units: delta, + })), + Request::KeyScancode { scancode, pressed } => { + let scancode = Scancode::from_u16(scancode); + self.input(if pressed { + Operation::KeyPressed(scancode) + } else { + Operation::KeyReleased(scancode) + }) + } + Request::KeyUnicode { ch, pressed } => self.input(if pressed { + Operation::UnicodeKeyPressed(ch) + } else { + Operation::UnicodeKeyReleased(ch) + }), + } + } + + fn connect(&self, mut properties: PropertySet, log_directive: Option) -> Response { + debug!(?log_directive, "Received connect request"); + // Refuse to clobber a live session: the previous RDP engine runs on its own thread and is + // not torn down by simply replacing the session slot. Require an explicit `disconnect` first. + { + let guard = self.state.lock().expect("daemon state poisoned"); + if let Some(session) = guard.as_ref() { + let state = session.live.lock().expect("session live state poisoned").state; + if matches!( + state, + ConnState::Connecting | ConnState::Connected | ConnState::Disconnecting + ) { + debug!("Refusing connect: a session is already active"); + return Response::error("a session is already active; disconnect first"); + } + } + } + + // Layer the operator-provided overlay on top (overlay wins), so any setting — credentials + // in particular — can be preconfigured without the (possibly untrusted) caller supplying it. + properties.merge(&self.overlay); + + let builder = match ConfigBuilder::from_property_set(&properties) { + Ok(builder) => builder, + Err(error) => return Response::error(format!("invalid configuration: {error:#}")), + }; + + // Derive the headless client identity. These fields are never representable as `.rdp` + // properties and are never prompted; the daemon supplies them itself. + let builder = builder + .with_client_build(client_build()) + .with_client_dir("C:\\Windows\\System32\\mstscax.dll") + .with_platform(current_platform()) + .with_client_name(client_name()) + // Headless: composite the remote cursor into the framebuffer so it appears in + // screenshots (there is no separate overlay to draw it). + .with_pointer_software_rendering(true); + + let missing = builder.missing(); + if !missing.is_empty() { + return Response::error(format!( + "missing required fields: {}", + missing + .iter() + .map(MissingField::to_string) + .collect::>() + .join(", ") + )); + } + + let config = match builder.build() { + Ok(config) => config, + Err(error) => return Response::error(format!("{error:#}")), + }; + + // `ConfigBuilder::build` strips every secret property, so the live bag carries no secrets. + let live_seed = config.properties().clone(); + let destination = config.destination().to_string(); + + let (output_tx, output_rx) = mpsc::channel(16); + let client = RdpClient::new(config, output_tx); + let input_tx = client.input_sender(); + + let live = Arc::new(Mutex::new(Live { + properties: live_seed, + state: ConnState::Connecting, + error: None, + frame: None, + })); + + // Capture this session's logs into the ring buffer (queryable via `Request::QueryLogs`) + // instead of the daemon's terminal, refined by the caller-supplied directive. The dispatch + // is installed as the session thread's thread-local default below. + let dispatch = logbuf::session_dispatch(Arc::clone(&self.logs), log_directive.as_deref()); + + // The RDP client engine runs on its own thread with a current-thread runtime, mirroring + // `ironrdp-viewer`. This sidesteps any `Send` requirement on the connection future. + let spawn_result = std::thread::Builder::new() + .name("ironrdp-agent-session".to_owned()) + .spawn(move || { + tracing::dispatcher::with_default(&dispatch, || { + match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(runtime) => runtime.block_on(client.run()), + Err(error) => error!(%error, "Failed to build the session runtime"), + } + }); + }); + if let Err(error) = spawn_result { + return Response::error(format!("failed to spawn session thread: {error}")); + } + + tokio::spawn(consume_output(output_rx, Arc::clone(&live))); + + info!(%destination, "Started RDP session"); + + *self.state.lock().expect("daemon state poisoned") = Some(Session { + input_tx, + input_db: Database::new(), + destination, + live, + }); + + Response::ok() + } + + fn disconnect(&self) -> Response { + let mut guard = self.state.lock().expect("daemon state poisoned"); + match guard.as_mut() { + None => { + debug!("Disconnect requested but no session is active"); + Response::error("no active session") + } + Some(session) => { + let mut live = session.live.lock().expect("session live state poisoned"); + match live.state { + ConnState::Connecting | ConnState::Connected => { + info!(destination = %session.destination, "Disconnecting RDP session"); + // Request a graceful shutdown and move to `Disconnecting`. The engine thread + // keeps running until it drains the close; `consume_output` flips the state + // to a terminal one once it does, which is what re-enables `connect`. Leaving + // it `Connected` here would let a new `connect` race the still-live thread. + let _ = session.input_tx.send(RdpInputEvent::Close); + live.state = ConnState::Disconnecting; + Response::ok() + } + // Already shutting down or terminated: nothing to do (idempotent). + _ => Response::ok(), + } + } + } + } + + fn status(&self) -> Response { + let guard = self.state.lock().expect("daemon state poisoned"); + let info = match guard.as_ref() { + None => StatusInfo { + state: ConnState::NoSession, + destination: None, + width: None, + height: None, + message: None, + credentials_loaded: self.credentials_loaded, + }, + Some(session) => { + let live = session.live.lock().expect("session live state poisoned"); + let (width, height) = match &live.frame { + Some(frame) => (Some(frame.width), Some(frame.height)), + None => (None, None), + }; + StatusInfo { + state: live.state, + destination: Some(session.destination.clone()), + width, + height, + message: live.error.clone(), + credentials_loaded: self.credentials_loaded, + } + } + }; + Response::Ok(Payload::Status(info)) + } + + fn query_props(&self, filter: Option<&KeyFilter>) -> Response { + let guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_ref() else { + return Response::error("no active session"); + }; + let live = session.live.lock().expect("session live state poisoned"); + + let mut entries = Vec::new(); + for (key, value) in live.properties.iter() { + let key = key.as_ref(); + if filter.is_some_and(|filter| !filter.matches(key)) { + continue; + } + let value = match value { + Value::Int(value) => PropValue::Int(*value), + Value::Str(value) => PropValue::Str(value.clone()), + }; + entries.push(PropertyEntry { + key: key.to_owned(), + value, + }); + } + + Response::Ok(Payload::Properties(PropertyDump { entries })) + } + + fn query_logs(&self, substring: Option<&str>, last: Option) -> Response { + let mut lines = self.logs.query(substring); + if let Some(last) = last { + let last = usize::try_from(last).unwrap_or(usize::MAX); + if last < lines.len() { + lines.drain(0..lines.len() - last); + } + } + Response::Ok(Payload::Logs(lines)) + } + + fn screenshot(&self) -> Response { + let guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_ref() else { + return Response::error("no active session"); + }; + let live = session.live.lock().expect("session live state poisoned"); + let Some(frame) = live.frame.as_ref() else { + return Response::error("no frame available yet"); + }; + match encode_png(frame.width, frame.height, &frame.pixels) { + Ok(png) => { + debug!( + width = frame.width, + height = frame.height, + bytes = png.len(), + "Encoded screenshot" + ); + Response::Ok(Payload::Screenshot { + width: frame.width, + height: frame.height, + png, + }) + } + Err(error) => Response::error(format!("failed to encode screenshot: {error:#}")), + } + } + + fn input(&self, operation: Operation) -> Response { + let mut guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_mut() else { + return Response::error("no active session"); + }; + let events = session.input_db.apply([operation]); + if events.is_empty() { + return Response::ok(); + } + match session.input_tx.send(RdpInputEvent::FastPath(events)) { + Ok(()) => Response::ok(), + Err(_) => Response::error("session input channel is closed"), + } + } +} + +/// Consumes the bounded output-event stream, keeping the live state current. +async fn consume_output(mut output_rx: mpsc::Receiver, live: Arc>) { + while let Some(event) = output_rx.recv().await { + let mut guard = live.lock().expect("session live state poisoned"); + let previous = guard.state; + match event { + RdpOutputEvent::Image { buffer, width, height } => { + let width = width.get(); + let height = height.get(); + guard.properties.insert("desktopwidth", width); + guard.properties.insert("desktopheight", height); + guard.frame = Some(Frame { + width, + height, + pixels: buffer, + }); + guard.state = ConnState::Connected; + guard.error = None; + if previous != ConnState::Connected { + info!(width, height, "Session connected"); + } + } + RdpOutputEvent::ConnectionFailure(error) => { + guard.state = ConnState::Failed; + guard.error = Some(format!("{error}")); + error!(%error, "Session connection failed"); + } + RdpOutputEvent::Terminated(Ok(reason)) => { + guard.state = ConnState::Disconnected; + guard.error = Some(format!("{reason:?}")); + info!(?reason, "Session terminated"); + } + RdpOutputEvent::Terminated(Err(error)) => { + guard.state = ConnState::Failed; + guard.error = Some(format!("{error}")); + warn!(%error, "Session terminated with an error"); + } + // With software pointer rendering the cursor is composited into the `Image` frames + // above; the remaining pointer events (default/hidden) carry no live state we track. + _ => {} + } + } + + // The engine thread has ended (channel closed). Resolve any transient state so a subsequent + // `connect` is not blocked indefinitely, even if no explicit `Terminated` event was emitted. + let mut guard = live.lock().expect("session live state poisoned"); + if matches!( + guard.state, + ConnState::Connecting | ConnState::Connected | ConnState::Disconnecting + ) { + guard.state = ConnState::Disconnected; + } +} + +/// Encodes a retained framebuffer to PNG bytes. +/// +/// `pixels` are `0x00RRGGBB` (`to_be_bytes()` yields `[0, R, G, B]`); the leading byte is the unused +/// alpha placeholder, so we emit opaque 8-bit RGB. +fn encode_png(width: u16, height: u16, pixels: &[u32]) -> anyhow::Result> { + let mut rgb = Vec::with_capacity(pixels.len() * 3 /* RGB */); + for pixel in pixels { + let [_, r, g, b] = pixel.to_be_bytes(); + rgb.extend_from_slice(&[r, g, b]); + } + + let mut png = Vec::new(); + let mut encoder = png::Encoder::new(&mut png, u32::from(width), u32::from(height)); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().context("write PNG header")?; + writer.write_image_data(&rgb).context("write PNG image data")?; + writer.finish().context("finish PNG stream")?; + Ok(png) +} + +/// Installs the daemon's global tracing subscriber: a compact formatter to stderr, defaulting to +/// `INFO` and tunable via `IRONRDP_LOG`. +/// +/// This is the daemon's *own* operational logging (IPC handling, lifecycle), mirroring +/// `ironrdp-viewer` but quieter by default. The RDP session's logs are captured separately into a +/// ring buffer (see [`logbuf::session_dispatch`]). Best-effort: a no-op if a global subscriber is +/// already set. +fn init_daemon_logging() { + use tracing::level_filters::LevelFilter; + use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; + + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .with_env_var("IRONRDP_LOG") + .from_env_lossy(); + + let fmt_layer = tracing_subscriber::fmt::layer().compact().with_writer(std::io::stderr); + + let _ = tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .try_init(); +} + +/// Derives a build number from the crate version (`major*100 + minor*10 + patch`). +fn client_build() -> u32 { + let mut parts = env!("CARGO_PKG_VERSION") + .split('.') + .map(|part| part.parse::().unwrap_or(0)); + let major = parts.next().unwrap_or(0); + let minor = parts.next().unwrap_or(0); + let patch = parts.next().unwrap_or(0); + major + .saturating_mul(100) + .saturating_add(minor.saturating_mul(10)) + .saturating_add(patch) +} + +fn client_name() -> String { + whoami::hostname().unwrap_or_else(|_| "ironrdp-agent".to_owned()) +} + +fn current_platform() -> MajorPlatformType { + match whoami::platform() { + whoami::Platform::Windows => MajorPlatformType::WINDOWS, + whoami::Platform::Linux => MajorPlatformType::UNIX, + whoami::Platform::Mac => MajorPlatformType::MACINTOSH, + whoami::Platform::Ios => MajorPlatformType::IOS, + whoami::Platform::Android => MajorPlatformType::ANDROID, + _ => MajorPlatformType::UNSPECIFIED, + } +} diff --git a/crates/ironrdp-agent/src/help.rs b/crates/ironrdp-agent/src/help.rs new file mode 100644 index 0000000000..40301fd541 --- /dev/null +++ b/crates/ironrdp-agent/src/help.rs @@ -0,0 +1,77 @@ +//! The `--help-agent` guide: a concise, structured, LLM-friendly description of every operation. + +/// Structured guide printed by `ironrdp-agent --help-agent`. +pub(crate) const AGENT_GUIDE: &str = r#"# ironrdp-agent + +A CLI-driven, daemon-backed RDP client. One binary plays two roles: + +- DAEMON: `ironrdp-agent daemon-start` runs a long-lived foreground process that owns the RDP + engine and one RDP session. Background it yourself (e.g. `ironrdp-agent daemon-start &`). +- CLI: every other subcommand opens the local IPC endpoint, sends one request, prints the + response, and exits. + +The daemon stays alive across CLI invocations. One daemon serves one RDP session. + +## Endpoint + +Unix: `$XDG_RUNTIME_DIR/ironrdp-agent-.sock` (falls back to `/tmp/ironrdp-agent-.sock`). +Windows: `\\.\pipe\ironrdp-agent-`. +Override with `--endpoint ` on any subcommand. + +## Lifecycle + +- `daemon-start [--overlay FILE]` + Start the daemon (foreground). Run this first. `--overlay` + preloads a .rdp file as an overlay applied to every `connect` + (overlay wins), letting an operator provision any setting out of + band -- credentials in particular (e.g. the password). Check + `status` to see whether credentials are already loaded before + supplying any yourself. +- `connect [--rdp-file F] [--server H[:PORT]] [-u USER] [-p PASS] [-d DOMAIN] [--log-directive D]` + Merge an optional .rdp file with CLI overrides into one config and + open a session. CLI flags win over the .rdp file. The config is + validated by the daemon, which replies with an error listing any + missing or invalid fields. If `status` reports + `credentials loaded: true`, omit `-p/--password` (and any other + preloaded secret) -- the daemon supplies it. `--log-directive` + refines this session's log capture (e.g. `ironrdp_connector=trace`) + on top of the default `debug` level; use it to troubleshoot a + connection, then read the result with `query-logs`. +- `disconnect` Tear down the current session (daemon keeps running). +- `status` Report connection state, destination, last frame size, and whether + credentials are preloaded (`credentials loaded: true|false`). Query + this first to decide whether you must supply a password. + +## Inspection + +- `query-props [--filter SUBSTR] [--prefix PREFIX]` + Print the live session property bag, one `key = value` per line. + Secrets are stripped from the configuration before a session + starts, so the dump never contains passwords or tokens. + `--filter` matches keys by substring; `--prefix` by prefix + (both case-insensitive). +- `query-logs [--substring S] [--last N]` + Print retained RDP session log lines (a bounded in-memory ring + buffer, default level `debug`). `--substring` filters to matching + lines; `--last N` keeps the last N. Raise verbosity for a specific + session with `connect --log-directive`. This is the session's own + log; the daemon's operational log goes to stderr (default `info`, + tune with the `IRONRDP_LOG` env var). +- `screenshot [PATH]` Capture the most recent frame (with the mouse cursor composited in) + as a PNG and write it to PATH (default `screenshot.png`). Prints + `wrote PATH (WxH, N bytes)`. Errors with `no frame available yet` + until the first frame arrives. + +## Input (require an active session) + +- `mouse-move --x X --y Y` Move the pointer to an absolute position. +- `mouse-button --button --pressed ` +- `wheel --delta N [--horizontal]` Rotate the wheel (negative N scrolls down/left). +- `key-scancode --scancode <0x1D|29> --pressed ` +- `key-unicode --char C --pressed ` Type by Unicode character. + +## Errors + +Failures print a single lowercase message (no trailing punctuation) and exit non-zero. A failed +`connect` carries the list of missing required fields. +"#; diff --git a/crates/ironrdp-agent/src/ipc.rs b/crates/ironrdp-agent/src/ipc.rs new file mode 100644 index 0000000000..cf50dda1e6 --- /dev/null +++ b/crates/ironrdp-agent/src/ipc.rs @@ -0,0 +1,778 @@ +//! Strictly-typed IPC schema (V1) and its binary codec. +//! +//! # Framing +//! +//! Every message is sent length-delimited: a little-endian `u32` byte-count prefix followed by the +//! `Encode`d body. The framing is identical over Unix domain sockets and Windows named pipes (see +//! [`crate::transport`]). Both ends are the same binary at the same version, so there is no version +//! byte and no forward/backward-compatibility handling. +//! +//! # Schema +//! +//! Connection configuration travels as a binary-encoded [`PropertySet`] inside [`Request::Connect`]; +//! everything else is a strictly-typed message. See [`Request`]/[`Response`]. + +use core::fmt; + +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size}; +use ironrdp_input::MouseButton; +use ironrdp_pdu::impl_pdu_pod; +use ironrdp_propertyset::PropertySet; + +use crate::wire::propertyset; +use crate::wire::{ + bytes_size, opt_string_size, opt_u16_size, read_bool, read_bytes, read_char, read_mouse_button, read_opt_string, + read_opt_u16, read_string, string_size, write_bool, write_bytes, write_char, write_mouse_button, write_opt_string, + write_opt_u16, write_string, +}; + +/// A request sent by the CLI to the daemon. +/// +/// `Connect` carries a binary-encoded [`PropertySet`] — never `argv` or CLI strings. Runtime +/// operations are strictly-typed. +#[derive(Clone, PartialEq, Eq)] +pub enum Request { + /// Start an RDP session from a fully-merged property bag. + /// + /// `log_directive`, when set, is a [`tracing`]-style filter directive applied to *this* + /// session's log capture (e.g. `ironrdp_connector=trace`), layered on top of the default + /// `DEBUG` level. It lets a caller raise verbosity up-front to troubleshoot a connection. + Connect { + properties: PropertySet, + log_directive: Option, + }, + /// Tear down the current RDP session (the daemon keeps running). + Disconnect, + /// Query the current session status. + Status, + /// Query the live session property bag, optionally filtered. + QueryProps { filter: Option }, + /// Return retained log lines, optionally filtered by substring and/or limited to the last `n`. + QueryLogs { + substring: Option, + last: Option, + }, + /// Capture the most recent frame (cursor composited in) as a PNG. + Screenshot, + /// Move the mouse pointer to an absolute position. + MouseMove { x: u16, y: u16 }, + /// Press or release a mouse button. + MouseButton { button: MouseButton, pressed: bool }, + /// Rotate the mouse wheel. + Wheel { delta: i16, horizontal: bool }, + // TODO: questioning whether we need a way to send multiple keys at once, e.g. a small mini + // format to express in a single command that keys A and B are pressed while key C is released. + // This could save LLM tokens by collapsing several round-trips into one request. + /// Press or release a key identified by its RDP scancode. + KeyScancode { scancode: u16, pressed: bool }, + /// Press or release a key identified by a Unicode character. + KeyUnicode { ch: char, pressed: bool }, + // TODO: add clipboard support (CLIPRDR), e.g. requests to read the remote clipboard text and to + // set it, so an LLM can copy/paste to and from the session. +} + +// Manual `Debug` so the `Connect` payload's property *values* (which may include a password before +// it reaches `ConfigBuilder::build`) are never printed verbatim; only the keys are shown. +impl fmt::Debug for Request { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connect { + properties, + log_directive, + } => f + .debug_struct("Connect") + .field("properties", &PropertyKeys(properties)) + .field("log_directive", log_directive) + .finish(), + Self::Disconnect => f.write_str("Disconnect"), + Self::Status => f.write_str("Status"), + Self::QueryProps { filter } => f.debug_struct("QueryProps").field("filter", filter).finish(), + Self::QueryLogs { substring, last } => f + .debug_struct("QueryLogs") + .field("substring", substring) + .field("last", last) + .finish(), + Self::Screenshot => f.write_str("Screenshot"), + Self::MouseMove { x, y } => f.debug_struct("MouseMove").field("x", x).field("y", y).finish(), + Self::MouseButton { button, pressed } => f + .debug_struct("MouseButton") + .field("button", button) + .field("pressed", pressed) + .finish(), + Self::Wheel { delta, horizontal } => f + .debug_struct("Wheel") + .field("delta", delta) + .field("horizontal", horizontal) + .finish(), + Self::KeyScancode { scancode, pressed } => f + .debug_struct("KeyScancode") + .field("scancode", scancode) + .field("pressed", pressed) + .finish(), + Self::KeyUnicode { ch, pressed } => f + .debug_struct("KeyUnicode") + .field("ch", ch) + .field("pressed", pressed) + .finish(), + } + } +} + +/// A [`PropertySet`] whose `Debug` output lists only the keys, never the (possibly secret) values. +struct PropertyKeys<'a>(&'a PropertySet); + +impl fmt::Debug for PropertyKeys<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_set().entries(self.0.iter().map(|(key, _)| key)).finish() + } +} + +/// The daemon's reply to a [`Request`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Response { + /// Success, carrying an operation-specific [`Payload`]. + Ok(Payload), + /// Failure. The message is lowercase with no trailing punctuation. + Err(String), +} + +impl Response { + /// A successful response with no payload. + pub fn ok() -> Self { + Self::Ok(Payload::Empty) + } + + /// A failure response. + pub fn error(message: impl Into) -> Self { + Self::Err(message.into()) + } + + /// Whether this is a success response. + pub fn is_ok(&self) -> bool { + matches!(self, Self::Ok(_)) + } +} + +/// The success payload carried by [`Response::Ok`]. +#[derive(Clone, PartialEq, Eq)] +pub enum Payload { + /// No data. + Empty, + /// Current session status. + Status(StatusInfo), + /// A dump of the live property bag. + Properties(PropertyDump), + /// Retained log lines. + Logs(Vec), + /// The most recent frame encoded as a PNG (cursor included), with its dimensions. + Screenshot { width: u16, height: u16, png: Vec }, +} + +impl fmt::Debug for Payload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("Empty"), + Self::Status(status) => f.debug_tuple("Status").field(status).finish(), + Self::Properties(dump) => f.debug_tuple("Properties").field(dump).finish(), + Self::Logs(lines) => f.debug_tuple("Logs").field(lines).finish(), + // Print the PNG byte length rather than the (large, binary) blob. + Self::Screenshot { width, height, png } => f + .debug_struct("Screenshot") + .field("width", width) + .field("height", height) + .field("png_len", &png.len()) + .finish(), + } + } +} + +/// Coarse connection state reported by [`Request::Status`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnState { + /// No session has been started. + NoSession, + /// A session was started and is connecting. + Connecting, + /// A session is active (at least one frame received). + Connected, + /// A graceful disconnect was requested; the engine thread is still shutting down. + Disconnecting, + /// A session terminated gracefully. + Disconnected, + /// A session failed. + Failed, +} + +impl ConnState { + fn tag(self) -> u8 { + match self { + Self::NoSession => 0, + Self::Connecting => 1, + Self::Connected => 2, + Self::Disconnected => 3, + Self::Failed => 4, + Self::Disconnecting => 5, + } + } + + fn from_tag(tag: u8) -> DecodeResult { + match tag { + 0 => Ok(Self::NoSession), + 1 => Ok(Self::Connecting), + 2 => Ok(Self::Connected), + 3 => Ok(Self::Disconnected), + 4 => Ok(Self::Failed), + 5 => Ok(Self::Disconnecting), + _ => Err(ironrdp_core::invalid_field_err!("connection state", "unknown tag")), + } + } +} + +/// Status snapshot returned by [`Request::Status`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusInfo { + /// Coarse connection state. + pub state: ConnState, + /// RDP target (`host:port`), if a session exists. + pub destination: Option, + /// Most recent frame width, if any. + pub width: Option, + /// Most recent frame height, if any. + pub height: Option, + /// Human-readable detail, e.g. the failure reason. + pub message: Option, + /// `true` when the daemon was started with preloaded credentials (an operator-provided overlay). + /// + /// When set, a caller driving `connect` does not need to supply a password (or other secrets): + /// the daemon layers the overlay on top of the request before building the configuration. + pub credentials_loaded: bool, +} + +/// A bulk dump of live properties. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PropertyDump { + /// One entry per property, in key order. + pub entries: Vec, +} + +/// A single dumped property. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PropertyEntry { + /// Property key. + pub key: String, + /// Property value. + pub value: PropValue, +} + +/// A dumped property value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PropValue { + /// Integer value. + Int(i64), + /// String value. + Str(String), +} + +/// A small key filter for [`Request::QueryProps`]. Matching is case-insensitive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyFilter { + /// Match keys containing this substring. + Substring(String), + /// Match keys starting with this prefix. + Prefix(String), +} + +impl KeyFilter { + /// Returns `true` when `key` matches this filter (case-insensitive). + pub fn matches(&self, key: &str) -> bool { + let key = key.to_ascii_lowercase(); + match self { + Self::Substring(needle) => key.contains(&needle.to_ascii_lowercase()), + Self::Prefix(prefix) => key.starts_with(&prefix.to_ascii_lowercase()), + } + } +} + +// ── KeyFilter codec ───────────────────────────────────────────────────────── + +impl Encode for KeyFilter { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Substring(value) => { + dst.write_u8(0); + write_string(dst, value) + } + Self::Prefix(value) => { + dst.write_u8(1); + write_string(dst, value) + } + } + } + + fn name(&self) -> &'static str { + "ironrdp_agent::KeyFilter" + } + + fn size(&self) -> usize { + let value = match self { + Self::Substring(value) | Self::Prefix(value) => value, + }; + 1 /* tag */ + string_size(value) + } +} + +impl Decode<'_> for KeyFilter { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(Self::Substring(read_string(src)?)), + 1 => Ok(Self::Prefix(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err!("key filter", "unknown tag")), + } + } +} + +impl_pdu_pod!(KeyFilter); + +// ── PropValue / PropertyEntry / PropertyDump codec ────────────────────────── + +impl Encode for PropValue { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Int(value) => { + dst.write_u8(0); + dst.write_i64(*value); + } + Self::Str(value) => { + dst.write_u8(1); + write_string(dst, value)?; + } + } + Ok(()) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::PropValue" + } + + fn size(&self) -> usize { + 1 /* tag */ + + match self { + Self::Int(_) => 8, + Self::Str(value) => string_size(value), + } + } +} + +impl Decode<'_> for PropValue { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => { + ensure_size!(in: src, size: 8); + Ok(Self::Int(src.read_i64())) + } + 1 => Ok(Self::Str(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err!("property value", "unknown tag")), + } + } +} + +impl_pdu_pod!(PropValue); + +impl Encode for PropertyEntry { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + write_string(dst, &self.key)?; + self.value.encode(dst) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::PropertyEntry" + } + + fn size(&self) -> usize { + string_size(&self.key) + self.value.size() + } +} + +impl Decode<'_> for PropertyEntry { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let key = read_string(src)?; + let value = PropValue::decode(src)?; + Ok(Self { key, value }) + } +} + +impl_pdu_pod!(PropertyEntry); + +impl Encode for PropertyDump { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + let count: u32 = cast_length!("property count", self.entries.len())?; + dst.write_u32(count); + for entry in &self.entries { + entry.encode(dst)?; + } + Ok(()) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::PropertyDump" + } + + fn size(&self) -> usize { + 4 /* count */ + self.entries.iter().map(Encode::size).sum::() + } +} + +impl Decode<'_> for PropertyDump { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 4); + let count = src.read_u32(); + let mut entries = Vec::new(); + for _ in 0..count { + entries.push(PropertyEntry::decode(src)?); + } + Ok(Self { entries }) + } +} + +impl_pdu_pod!(PropertyDump); + +// ── StatusInfo codec ──────────────────────────────────────────────────────── + +impl Encode for StatusInfo { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + dst.write_u8(self.state.tag()); + write_opt_string(dst, self.destination.as_deref())?; + write_opt_u16(dst, self.width)?; + write_opt_u16(dst, self.height)?; + write_opt_string(dst, self.message.as_deref())?; + write_bool(dst, self.credentials_loaded) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::StatusInfo" + } + + fn size(&self) -> usize { + 1 /* state */ + + opt_string_size(self.destination.as_deref()) + + opt_u16_size(self.width) + + opt_u16_size(self.height) + + opt_string_size(self.message.as_deref()) + + 1 /* credentials_loaded */ + } +} + +impl Decode<'_> for StatusInfo { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + let state = ConnState::from_tag(src.read_u8())?; + let destination = read_opt_string(src)?; + let width = read_opt_u16(src)?; + let height = read_opt_u16(src)?; + let message = read_opt_string(src)?; + let credentials_loaded = read_bool(src)?; + Ok(Self { + state, + destination, + width, + height, + message, + credentials_loaded, + }) + } +} + +impl_pdu_pod!(StatusInfo); + +// ── Payload codec ─────────────────────────────────────────────────────────── + +impl Encode for Payload { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Empty => dst.write_u8(0), + Self::Status(status) => { + dst.write_u8(1); + status.encode(dst)?; + } + Self::Properties(dump) => { + dst.write_u8(2); + dump.encode(dst)?; + } + Self::Logs(lines) => { + dst.write_u8(3); + let count: u32 = cast_length!("log line count", lines.len())?; + dst.write_u32(count); + for line in lines { + write_string(dst, line)?; + } + } + Self::Screenshot { width, height, png } => { + dst.write_u8(4); + dst.write_u16(*width); + dst.write_u16(*height); + write_bytes(dst, png)?; + } + } + Ok(()) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::Payload" + } + + fn size(&self) -> usize { + 1 /* tag */ + + match self { + Self::Empty => 0, + Self::Status(status) => status.size(), + Self::Properties(dump) => dump.size(), + Self::Logs(lines) => 4 + lines.iter().map(|line| string_size(line)).sum::(), + Self::Screenshot { png, .. } => 2 /* width */ + 2 /* height */ + bytes_size(png), + } + } +} + +impl Decode<'_> for Payload { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(Self::Empty), + 1 => Ok(Self::Status(StatusInfo::decode(src)?)), + 2 => Ok(Self::Properties(PropertyDump::decode(src)?)), + 3 => { + ensure_size!(in: src, size: 4); + let count = src.read_u32(); + let mut lines = Vec::new(); + for _ in 0..count { + lines.push(read_string(src)?); + } + Ok(Self::Logs(lines)) + } + 4 => { + ensure_size!(in: src, size: 4); + let width = src.read_u16(); + let height = src.read_u16(); + let png = read_bytes(src)?; + Ok(Self::Screenshot { width, height, png }) + } + _ => Err(ironrdp_core::invalid_field_err!("payload", "unknown tag")), + } + } +} + +impl_pdu_pod!(Payload); + +// ── Response codec ────────────────────────────────────────────────────────── + +impl Encode for Response { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Ok(payload) => { + dst.write_u8(0); + payload.encode(dst) + } + Self::Err(message) => { + dst.write_u8(1); + write_string(dst, message) + } + } + } + + fn name(&self) -> &'static str { + "ironrdp_agent::Response" + } + + fn size(&self) -> usize { + 1 /* tag */ + + match self { + Self::Ok(payload) => payload.size(), + Self::Err(message) => string_size(message), + } + } +} + +impl Decode<'_> for Response { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(Self::Ok(Payload::decode(src)?)), + 1 => Ok(Self::Err(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err!("response", "unknown tag")), + } + } +} + +impl_pdu_pod!(Response); + +// ── Request codec ─────────────────────────────────────────────────────────── + +impl Encode for Request { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Connect { + properties, + log_directive, + } => { + dst.write_u8(0); + propertyset::write(properties, dst)?; + write_opt_string(dst, log_directive.as_deref())?; + } + Self::Disconnect => dst.write_u8(1), + Self::Status => dst.write_u8(2), + Self::QueryProps { filter } => { + dst.write_u8(3); + match filter { + Some(filter) => { + dst.write_u8(1); + filter.encode(dst)?; + } + None => dst.write_u8(0), + } + } + Self::QueryLogs { substring, last } => { + dst.write_u8(4); + write_opt_string(dst, substring.as_deref())?; + match last { + Some(last) => { + dst.write_u8(1); + dst.write_u32(*last); + } + None => dst.write_u8(0), + } + } + Self::Screenshot => dst.write_u8(5), + Self::MouseMove { x, y } => { + dst.write_u8(6); + dst.write_u16(*x); + dst.write_u16(*y); + } + Self::MouseButton { button, pressed } => { + dst.write_u8(7); + write_mouse_button(dst, *button)?; + write_bool(dst, *pressed)?; + } + Self::Wheel { delta, horizontal } => { + dst.write_u8(8); + dst.write_i16(*delta); + write_bool(dst, *horizontal)?; + } + Self::KeyScancode { scancode, pressed } => { + dst.write_u8(9); + dst.write_u16(*scancode); + write_bool(dst, *pressed)?; + } + Self::KeyUnicode { ch, pressed } => { + dst.write_u8(10); + write_char(dst, *ch)?; + write_bool(dst, *pressed)?; + } + } + Ok(()) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::Request" + } + + fn size(&self) -> usize { + 1 /* tag */ + + match self { + Self::Connect { properties, log_directive } => { + propertyset::size(properties) + opt_string_size(log_directive.as_deref()) + } + Self::Disconnect | Self::Status | Self::Screenshot => 0, + Self::QueryProps { filter } => 1 /* presence */ + filter.as_ref().map_or(0, Encode::size), + Self::QueryLogs { substring, last } => { + opt_string_size(substring.as_deref()) + 1 /* presence */ + last.map_or(0, |_| 4) + } + Self::MouseMove { .. } => 2 /* x */ + 2 /* y */, + Self::MouseButton { .. } => 1 /* button */ + 1 /* pressed */, + Self::Wheel { .. } => 2 /* delta */ + 1 /* horizontal */, + Self::KeyScancode { .. } => 2 /* scancode */ + 1 /* pressed */, + Self::KeyUnicode { .. } => 4 /* ch */ + 1 /* pressed */, + } + } +} + +impl Decode<'_> for Request { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => { + let mut properties = PropertySet::new(); + propertyset::read(&mut properties, src)?; + let log_directive = read_opt_string(src)?; + Ok(Self::Connect { + properties, + log_directive, + }) + } + 1 => Ok(Self::Disconnect), + 2 => Ok(Self::Status), + 3 => { + ensure_size!(in: src, size: 1); + let filter = match src.read_u8() { + 0 => None, + 1 => Some(KeyFilter::decode(src)?), + _ => return Err(ironrdp_core::invalid_field_err!("dump filter", "invalid presence flag")), + }; + Ok(Self::QueryProps { filter }) + } + 4 => { + let substring = read_opt_string(src)?; + ensure_size!(in: src, size: 1); + let last = match src.read_u8() { + 0 => None, + 1 => { + ensure_size!(in: src, size: 4); + Some(src.read_u32()) + } + _ => return Err(ironrdp_core::invalid_field_err!("query last", "invalid presence flag")), + }; + Ok(Self::QueryLogs { substring, last }) + } + 5 => Ok(Self::Screenshot), + 6 => { + ensure_size!(in: src, size: 4); + let x = src.read_u16(); + let y = src.read_u16(); + Ok(Self::MouseMove { x, y }) + } + 7 => { + let button = read_mouse_button(src)?; + let pressed = read_bool(src)?; + Ok(Self::MouseButton { button, pressed }) + } + 8 => { + ensure_size!(in: src, size: 2); + let delta = src.read_i16(); + let horizontal = read_bool(src)?; + Ok(Self::Wheel { delta, horizontal }) + } + 9 => { + ensure_size!(in: src, size: 2); + let scancode = src.read_u16(); + let pressed = read_bool(src)?; + Ok(Self::KeyScancode { scancode, pressed }) + } + 10 => { + let ch = read_char(src)?; + let pressed = read_bool(src)?; + Ok(Self::KeyUnicode { ch, pressed }) + } + _ => Err(ironrdp_core::invalid_field_err!("request", "unknown tag")), + } + } +} + +impl_pdu_pod!(Request); diff --git a/crates/ironrdp-agent/src/lib.rs b/crates/ironrdp-agent/src/lib.rs new file mode 100644 index 0000000000..7028107a22 --- /dev/null +++ b/crates/ironrdp-agent/src/lib.rs @@ -0,0 +1,27 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] + +//! A CLI-driven, daemon-backed agentic RDP client. +//! +//! The public surface is intentionally small and split into: +//! +//! - [`ipc`]: the strictly-typed request/response schema and its binary codec. +//! - [`transport`]: the local IPC transport (Unix socket / Windows named pipe) and framing. +//! - [`daemon`]: the long-lived daemon driver. +//! - [`cli`]: the short-lived CLI driver. + +pub mod cli; +pub mod daemon; +pub mod ipc; +pub mod transport; + +pub(crate) mod help; +pub(crate) mod logbuf; + +// The wire codec helpers are internal, but the `internal` feature exposes them (hidden from docs) +// so they can be unit tested from the workspace test suite. +#[cfg(feature = "internal")] +#[doc(hidden)] +pub mod wire; +#[cfg(not(feature = "internal"))] +pub(crate) mod wire; diff --git a/crates/ironrdp-agent/src/logbuf.rs b/crates/ironrdp-agent/src/logbuf.rs new file mode 100644 index 0000000000..7d858dc2d8 --- /dev/null +++ b/crates/ironrdp-agent/src/logbuf.rs @@ -0,0 +1,147 @@ +//! The RDP session log ring buffer and its [`tracing`] layer. +//! +//! The logs emitted while driving the RDP engine are captured into a small, queryable +//! [`LogBuffer`] ring (read via `Request::QueryLogs`) instead of the terminal. The capture is +//! installed as a thread-local subscriber for the session thread only (see [`session_dispatch`] and +//! [`tracing::dispatcher::with_default`]), so it never becomes the global subscriber. It defaults +//! to `DEBUG`, which is useful when inspecting a session, and a per-`Connect` directive can refine +//! the filter (e.g. `ironrdp_connector=trace`) to troubleshoot IronRDP itself. +//! +//! The daemon's *own* operational logging is a separate concern; see +//! [`crate::daemon`]'s global subscriber setup. + +use core::fmt::Write as _; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use tracing::field::{Field, Visit}; +use tracing::{Dispatch, Event, Subscriber}; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; + +/// Default ring-buffer capacity, in lines. +const DEFAULT_CAPACITY: usize = 100; + +/// A bounded ring buffer of formatted log lines. +pub(crate) struct LogBuffer { + inner: Mutex, +} + +struct Inner { + capacity: usize, + lines: VecDeque, +} + +impl LogBuffer { + pub(crate) fn new() -> Arc { + Self::with_capacity(DEFAULT_CAPACITY) + } + + pub(crate) fn with_capacity(capacity: usize) -> Arc { + Arc::new(Self { + inner: Mutex::new(Inner { + capacity: capacity.max(1), + lines: VecDeque::new(), + }), + }) + } + + fn push(&self, line: String) { + let mut inner = self.inner.lock().expect("log buffer poisoned"); + + if inner.capacity <= inner.lines.len() { + inner.lines.pop_front(); + } + inner.lines.push_back(line); + } + + /// Returns retained lines, optionally filtered to those containing `substring`. + pub(crate) fn query(&self, substring: Option<&str>) -> Vec { + let inner = self.inner.lock().expect("log buffer poisoned"); + inner + .lines + .iter() + .filter(|line| substring.is_none_or(|needle| line.contains(needle))) + .cloned() + .collect() + } +} + +/// Builds a session-scoped [`Dispatch`] that routes the RDP session's logs into `buffer`. +/// +/// The session runs on its own thread; wrapping its execution in +/// [`tracing::dispatcher::with_default`] keeps the engine's events out of the daemon's terminal and +/// in the ring buffer instead. The default level is `DEBUG`; `directive` (carried by +/// `Request::Connect`) refines it per-session — a bare level sets the global session level, while a +/// targeted directive (e.g. `ironrdp_connector=trace`) layers on top of the `DEBUG` default. +pub(crate) fn session_dispatch(buffer: Arc, directive: Option<&str>) -> Dispatch { + use tracing::level_filters::LevelFilter; + use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; + + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::DEBUG.into()) + .parse_lossy(directive.unwrap_or("")); + + let subscriber = tracing_subscriber::registry() + .with(env_filter) + .with(LogLayer::new(buffer)); + + Dispatch::new(subscriber) +} + +/// A tracing [`Layer`] that formats each event into a single line and pushes it to a [`LogBuffer`]. +struct LogLayer { + buffer: Arc, +} + +impl LogLayer { + fn new(buffer: Arc) -> Self { + Self { buffer } + } +} + +impl Layer for LogLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let meta = event.metadata(); + + let mut visitor = LogVisitor { + message: None, + fields: String::new(), + }; + event.record(&mut visitor); + + let mut line = String::new(); + let _ = write!(line, "{:>5} {}", meta.level(), meta.target()); + if let Some(message) = &visitor.message { + let _ = write!(line, " {message}"); + } + line.push_str(&visitor.fields); + + self.buffer.push(line); + } +} + +/// Collects an event's message and structured fields into strings. +struct LogVisitor { + message: Option, + fields: String, +} + +impl Visit for LogVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn core::fmt::Debug) { + if field.name() == "message" { + self.message = Some(format!("{value:?}")); + } else { + let _ = write!(self.fields, " {}={:?}", field.name(), value); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = Some(value.to_owned()); + } else { + let _ = write!(self.fields, " {}={}", field.name(), value); + } + } +} diff --git a/crates/ironrdp-agent/src/main.rs b/crates/ironrdp-agent/src/main.rs new file mode 100644 index 0000000000..2d42729e21 --- /dev/null +++ b/crates/ironrdp-agent/src/main.rs @@ -0,0 +1,10 @@ +// The binary uses only a subset of the library's dependencies; the rest are used by the lib target. +#![allow(unused_crate_dependencies)] + +use clap::Parser as _; +use ironrdp_agent::cli::Cli; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + ironrdp_agent::cli::run(Cli::parse()).await +} diff --git a/crates/ironrdp-agent/src/transport.rs b/crates/ironrdp-agent/src/transport.rs new file mode 100644 index 0000000000..405ab72365 --- /dev/null +++ b/crates/ironrdp-agent/src/transport.rs @@ -0,0 +1,195 @@ +//! Local IPC transport and message framing. +//! +//! The daemon and CLI talk over a platform-native local transport: +//! +//! - **Unix**: a [`tokio::net::UnixListener`]/[`tokio::net::UnixStream`] at +//! `$XDG_RUNTIME_DIR/ironrdp-agent-.sock`, falling back to `/tmp/ironrdp-agent-.sock` +//! when `XDG_RUNTIME_DIR` is unset. +//! - **Windows**: a named pipe at `\\.\pipe\ironrdp-agent-`. +//! +//! Framing is identical on both: a little-endian `u32` byte-count prefix followed by the `Encode`d +//! message body. + +use anyhow::{Context as _, bail}; +use ironrdp_core::{DecodeOwned, Encode}; +use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; + +use crate::ipc::{Request, Response}; + +/// Upper bound on a single framed message, guarding against absurd length prefixes. +const MAX_MESSAGE_LEN: usize = 16 * 1024 * 1024; + +/// Writes `message` to `stream`, length-delimited. +pub(crate) async fn write_message(stream: &mut S, message: &M) -> anyhow::Result<()> +where + S: AsyncWrite + Unpin, + M: Encode, +{ + let body = ironrdp_core::encode_vec(message).map_err(|e| anyhow::anyhow!("encode {}: {e}", message.name()))?; + let len = u32::try_from(body.len()).context("message too large to frame")?; + stream + .write_all(&len.to_le_bytes()) + .await + .context("write frame length")?; + stream.write_all(&body).await.context("write frame body")?; + stream.flush().await.context("flush frame")?; + Ok(()) +} + +/// Reads a single length-delimited message from `stream`. +pub(crate) async fn read_message(stream: &mut S) -> anyhow::Result +where + S: AsyncRead + Unpin, + M: DecodeOwned, +{ + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read frame length")?; + let len = usize::try_from(u32::from_le_bytes(len_buf)).expect("u32 fits in usize on supported platforms"); + if MAX_MESSAGE_LEN < len { + bail!("frame length {len} exceeds the {MAX_MESSAGE_LEN}-byte limit"); + } + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read frame body")?; + ironrdp_core::decode_owned(&body).map_err(|e| anyhow::anyhow!("decode: {e}")) +} + +/// Opens the endpoint, sends one `request`, and returns the daemon's `Response`. +pub async fn send_request(endpoint: &Endpoint, request: &Request) -> anyhow::Result { + let mut stream = connect(endpoint) + .await + .with_context(|| format!("connect to daemon at {endpoint}"))?; + write_message(&mut stream, request).await?; + read_message(&mut stream).await +} + +#[cfg(unix)] +mod imp { + use std::io; + use std::path::PathBuf; + + use tokio::net::{UnixListener, UnixStream}; + + /// A resolved IPC endpoint (a Unix domain socket path). + #[derive(Debug, Clone)] + pub struct Endpoint(pub PathBuf); + + impl core::fmt::Display for Endpoint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0.display()) + } + } + + /// Returns the default per-user endpoint. + pub fn default_endpoint() -> Endpoint { + // SAFETY: `getuid` has no preconditions and is always safe to call. + let uid = unsafe { libc::getuid() }; + let dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + Endpoint(dir.join(format!("ironrdp-agent-{uid}.sock"))) + } + + /// Connects to a listening daemon. + pub async fn connect(endpoint: &Endpoint) -> io::Result { + UnixStream::connect(&endpoint.0).await + } + + /// A bound listener that accepts client connections. + pub struct Listener { + inner: UnixListener, + path: PathBuf, + } + + impl Listener { + /// Binds the listener at `endpoint`. + pub fn bind(endpoint: &Endpoint) -> io::Result { + let inner = UnixListener::bind(&endpoint.0)?; + // Restrict the socket to the owner. The fallback directory is world-writable `/tmp`, so + // without this any local user could connect and drive the session (input, screenshots, + // logs). Fail loudly rather than serve on a world-accessible endpoint. + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&endpoint.0, std::fs::Permissions::from_mode(0o600))?; + Ok(Self { + inner, + path: endpoint.0.clone(), + }) + } + + /// Accepts the next client connection. + pub async fn accept(&mut self) -> io::Result { + let (stream, _addr) = self.inner.accept().await?; + Ok(stream) + } + } + + impl Drop for Listener { + fn drop(&mut self) { + // Best-effort removal of the socket file on shutdown (named pipes need no cleanup). + let _ = std::fs::remove_file(&self.path); + } + } +} + +#[cfg(windows)] +mod imp { + use std::io; + + use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions}; + + /// A resolved IPC endpoint (a named pipe path). + #[derive(Debug, Clone)] + pub struct Endpoint(pub String); + + impl core::fmt::Display for Endpoint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0) + } + } + + /// Returns the default per-user endpoint. + pub fn default_endpoint() -> Endpoint { + let user = whoami::username().unwrap_or_else(|_| "user".to_owned()); + Endpoint(format!(r"\\.\pipe\ironrdp-agent-{user}")) + } + + /// Connects to a listening daemon. + pub async fn connect(endpoint: &Endpoint) -> io::Result { + ClientOptions::new().open(&endpoint.0) + } + + /// A named-pipe listener. + /// + /// It always keeps one ready (unconnected) server instance alive, which is both what serves the + /// next connection and what upholds the `first_pipe_instance` exclusivity (a pipe with no live + /// instance would let a second daemon claim the name). + pub struct Listener { + name: String, + ready: NamedPipeServer, + } + + impl Listener { + /// Creates the first pipe instance, claiming the name exclusively. + /// + /// `first_pipe_instance(true)` makes this fail with `ERROR_ACCESS_DENIED` if another daemon + /// already owns the pipe, so two daemons cannot coexist on the same endpoint. + pub fn bind(endpoint: &Endpoint) -> io::Result { + let ready = ServerOptions::new().first_pipe_instance(true).create(&endpoint.0)?; + Ok(Self { + name: endpoint.0.clone(), + ready, + }) + } + + /// Waits for the next client to connect to the ready instance, then mints a replacement. + pub async fn accept(&mut self) -> io::Result { + // Connect by reference so a cancelled future leaves `ready` intact (and the pipe alive). + self.ready.connect().await?; + // Mint the next listening instance before returning so the pipe is never instance-less. + // Subsequent instances must omit `first_pipe_instance`, which is only valid on the first. + let next = ServerOptions::new().create(&self.name)?; + Ok(core::mem::replace(&mut self.ready, next)) + } + } +} + +pub use imp::{Endpoint, Listener, connect, default_endpoint}; diff --git a/crates/ironrdp-agent/src/wire/mod.rs b/crates/ironrdp-agent/src/wire/mod.rs new file mode 100644 index 0000000000..cc8edc38e2 --- /dev/null +++ b/crates/ironrdp-agent/src/wire/mod.rs @@ -0,0 +1,160 @@ +//! Binary wire primitives shared by the IPC message codecs. +//! +//! Everything is little-endian and cursor-based so it composes directly with [`ironrdp_core`]'s +//! `Encode`/`Decode`/`DecodeOwned` traits. Strings (and string-shaped payloads) are length-delimited +//! with a `u32` byte-count prefix. +//! +//! These helpers are `pub` so the [`internal`](crate) feature can expose them for unit testing in +//! the workspace test suite; the [`wire`](crate::wire) module itself is only public under that +//! feature. + +// The helpers are unconditionally `pub`; their effective visibility is the `wire` module's, which is +// `pub(crate)` unless the `internal` feature exposes it. +#![cfg_attr(not(feature = "internal"), allow(unreachable_pub))] + +pub mod propertyset; + +use ironrdp_core::{DecodeResult, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size}; +use ironrdp_input::MouseButton; + +/// Size on the wire of a length-prefixed UTF-8 string. +pub fn string_size(value: &str) -> usize { + 4 /* length prefix */ + value.len() /* UTF-8 bytes */ +} + +/// Size on the wire of an optional length-prefixed UTF-8 string. +pub fn opt_string_size(value: Option<&str>) -> usize { + 1 /* presence flag */ + value.map_or(0, string_size) +} + +pub fn write_string(dst: &mut WriteCursor<'_>, value: &str) -> EncodeResult<()> { + ensure_size!(in: dst, size: string_size(value)); + let len: u32 = cast_length!("string length", value.len())?; + dst.write_u32(len); + dst.write_slice(value.as_bytes()); + Ok(()) +} + +pub fn read_string(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 4); + let len = src.read_u32(); + let len = usize::try_from(len).map_err(|_| ironrdp_core::other_err!("string", "length does not fit in usize"))?; + ensure_size!(in: src, size: len); + let bytes = src.read_slice(len); + String::from_utf8(bytes.to_vec()).map_err(|_| ironrdp_core::invalid_field_err!("string", "not valid UTF-8")) +} + +/// Size on the wire of a length-prefixed raw byte blob. +pub fn bytes_size(value: &[u8]) -> usize { + 4 /* length prefix */ + value.len() /* raw bytes */ +} + +pub fn write_bytes(dst: &mut WriteCursor<'_>, value: &[u8]) -> EncodeResult<()> { + ensure_size!(in: dst, size: bytes_size(value)); + let len: u32 = cast_length!("bytes length", value.len())?; + dst.write_u32(len); + dst.write_slice(value); + Ok(()) +} + +pub fn read_bytes(src: &mut ReadCursor<'_>) -> DecodeResult> { + ensure_size!(in: src, size: 4); + let len = src.read_u32(); + let len = usize::try_from(len).map_err(|_| ironrdp_core::other_err!("bytes", "length does not fit in usize"))?; + ensure_size!(in: src, size: len); + Ok(src.read_slice(len).to_vec()) +} + +pub fn write_opt_string(dst: &mut WriteCursor<'_>, value: Option<&str>) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + match value { + Some(value) => { + dst.write_u8(1); + write_string(dst, value) + } + None => { + dst.write_u8(0); + Ok(()) + } + } +} + +pub fn read_opt_string(src: &mut ReadCursor<'_>) -> DecodeResult> { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(None), + 1 => Ok(Some(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err!( + "optional string", + "invalid presence flag" + )), + } +} + +pub fn write_bool(dst: &mut WriteCursor<'_>, value: bool) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + dst.write_u8(u8::from(value)); + Ok(()) +} + +pub fn read_bool(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + Ok(src.read_u8() != 0) +} + +pub fn write_char(dst: &mut WriteCursor<'_>, value: char) -> EncodeResult<()> { + ensure_size!(in: dst, size: 4); + dst.write_u32(u32::from(value)); + Ok(()) +} + +pub fn read_char(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 4); + let code = src.read_u32(); + char::from_u32(code).ok_or_else(|| ironrdp_core::invalid_field_err!("char", "not a valid Unicode scalar value")) +} + +pub fn write_mouse_button(dst: &mut WriteCursor<'_>, button: MouseButton) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + let idx: u8 = cast_length!("mouse button index", button.as_idx())?; + dst.write_u8(idx); + Ok(()) +} + +pub fn read_mouse_button(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + let idx = src.read_u8(); + MouseButton::from_idx(usize::from(idx)) + .ok_or_else(|| ironrdp_core::invalid_field_err!("mouse button", "unknown button index")) +} + +pub fn opt_u16_size(value: Option) -> usize { + 1 /* presence */ + value.map_or(0, |_| 2) +} + +pub fn write_opt_u16(dst: &mut WriteCursor<'_>, value: Option) -> EncodeResult<()> { + ensure_size!(in: dst, size: opt_u16_size(value)); + match value { + Some(value) => { + dst.write_u8(1); + dst.write_u16(value); + } + None => dst.write_u8(0), + } + Ok(()) +} + +pub fn read_opt_u16(src: &mut ReadCursor<'_>) -> DecodeResult> { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(None), + 1 => { + ensure_size!(in: src, size: 2); + Ok(Some(src.read_u16())) + } + _ => Err(ironrdp_core::invalid_field_err!( + "optional u16", + "invalid presence flag" + )), + } +} diff --git a/crates/ironrdp-agent/src/wire/propertyset.rs b/crates/ironrdp-agent/src/wire/propertyset.rs new file mode 100644 index 0000000000..25f984deb6 --- /dev/null +++ b/crates/ironrdp-agent/src/wire/propertyset.rs @@ -0,0 +1,83 @@ +//! Binary wire codec for [`PropertySet`]. +//! +//! This mirrors the shape of [`ironrdp_rdpfile::load`]/[`ironrdp_rdpfile::write`] but is binary and +//! cursor-based so it composes with [`ironrdp_core`]'s `Encode`/`DecodeOwned` traits. +//! +//! Layout: a `u32` entry count, then for each entry a length-prefixed UTF-8 key, a 1-byte value tag +//! (`0` = `Int`, `1` = `Str`), and the value (an `i64`, or a length-prefixed UTF-8 string). +//! +//! [`ironrdp_rdpfile::load`]: https://docs.rs/ironrdp-rdpfile + +#![cfg_attr(not(feature = "internal"), allow(unreachable_pub))] + +use ironrdp_core::{DecodeResult, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size}; +use ironrdp_propertyset::{PropertySet, Value}; + +use crate::wire::{read_string, string_size, write_string}; + +const TAG_INT: u8 = 0; +const TAG_STR: u8 = 1; + +/// Size on the wire of `properties`, for use from an enclosing `Encode::size`. +pub fn size(properties: &PropertySet) -> usize { + let mut total = 4; // Entry count. + for (key, value) in properties.iter() { + total += string_size(key); // Key. + total += 1; // Value tag. + total += match value { + Value::Int(_) => 8, // i64. + Value::Str(value) => string_size(value), // Length-prefixed string. + }; + } + total +} + +/// Encodes `properties` into `dst`. +pub fn write(properties: &PropertySet, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: size(properties)); + + let count: u32 = cast_length!("property count", properties.iter().count())?; + dst.write_u32(count); + + for (key, value) in properties.iter() { + write_string(dst, key)?; + match value { + Value::Int(value) => { + dst.write_u8(TAG_INT); + dst.write_i64(*value); + } + Value::Str(value) => { + dst.write_u8(TAG_STR); + write_string(dst, value)?; + } + } + } + + Ok(()) +} + +/// Decodes entries from `src`, inserting them into `properties` (layering onto any existing keys, +/// matching the contract of [`ironrdp_rdpfile::load`]). +pub fn read(properties: &mut PropertySet, src: &mut ReadCursor<'_>) -> DecodeResult<()> { + ensure_size!(in: src, size: 4); + let count = src.read_u32(); + + for _ in 0..count { + let key = read_string(src)?; + + ensure_size!(in: src, size: 1); + match src.read_u8() { + TAG_INT => { + ensure_size!(in: src, size: 8); + properties.insert(key, src.read_i64()); + } + TAG_STR => { + let value = read_string(src)?; + properties.insert(key, value); + } + _ => return Err(ironrdp_core::invalid_field_err!("property value tag", "unknown tag")), + } + } + + Ok(()) +} diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index b892b4ef3b..46778e4080 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -556,6 +556,7 @@ pub struct ConfigBuilder { codecs: Vec, autologon: Option, enable_server_pointer: Option, + pointer_software_rendering: Option, enable_audio_playback: Option, compression_type: Option, compression_enabled: Option, @@ -780,6 +781,16 @@ impl ConfigBuilder { self } + /// Enable or disable software pointer rendering. When enabled, the session composites the + /// remote cursor directly into the decoded framebuffer (instead of emitting it as separate + /// pointer events for a hardware/overlay cursor). Useful for headless clients that have no + /// overlay of their own and want the cursor captured in the frame. + #[must_use] + pub fn with_pointer_software_rendering(mut self, enabled: bool) -> Self { + self.pointer_software_rendering = Some(enabled); + self + } + /// Enable or disable bulk compression support. Upserts the `compression` property. #[must_use] pub fn with_compression(mut self, enabled: bool) -> Self { @@ -1161,7 +1172,7 @@ impl ConfigBuilder { autologon: self.autologon.unwrap_or(false), enable_audio_playback: self.enable_audio_playback.unwrap_or(true), request_data: None, - pointer_software_rendering: false, + pointer_software_rendering: self.pointer_software_rendering.unwrap_or(false), multitransport_flags: None, compression_type, performance_flags: PerformanceFlags::default(), diff --git a/crates/ironrdp-testsuite-extra/Cargo.toml b/crates/ironrdp-testsuite-extra/Cargo.toml index 070d6f53a1..89c541562f 100644 --- a/crates/ironrdp-testsuite-extra/Cargo.toml +++ b/crates/ironrdp-testsuite-extra/Cargo.toml @@ -26,7 +26,11 @@ anyhow = "1.0" async-trait = "0.1" ironrdp = { path = "../ironrdp", features = ["server", "pdu", "connector", "session", "dvc", "echo"] } ironrdp-async.path = "../ironrdp-async" +ironrdp-agent = { path = "../ironrdp-agent", features = ["internal"] } ironrdp-client.path = "../ironrdp-client" +ironrdp-core.path = "../ironrdp-core" +ironrdp-input.path = "../ironrdp-input" +ironrdp-propertyset.path = "../ironrdp-propertyset" ironrdp-viewer.path = "../ironrdp-viewer" ironrdp-tokio.path = "../ironrdp-tokio" ironrdp-tls = { path = "../ironrdp-tls", features = ["rustls"] } diff --git a/crates/ironrdp-testsuite-extra/tests/agent.rs b/crates/ironrdp-testsuite-extra/tests/agent.rs new file mode 100644 index 0000000000..77e0391652 --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/agent.rs @@ -0,0 +1,176 @@ +//! Codec round-trip tests for the `ironrdp-agent` IPC and wire protocols. +//! +//! These exercise the crate's private wire format through its public (and `internal`-feature) +//! API. They live here, in the shared test suite, rather than inside `ironrdp-agent` itself, per +//! the workspace convention of keeping unit tests for protocol codecs in `ironrdp-testsuite-extra`. + +use core::fmt::Debug; + +use ironrdp_agent::ipc::{ + ConnState, KeyFilter, Payload, PropValue, PropertyDump, PropertyEntry, Request, Response, StatusInfo, +}; +use ironrdp_agent::wire; +use ironrdp_core::{Decode, DecodeOwned, Encode, decode, decode_owned, encode_vec}; +use ironrdp_input::MouseButton; +use ironrdp_propertyset::PropertySet; + +#[track_caller] +fn round_trip(value: &T) +where + T: Encode + DecodeOwned + for<'de> Decode<'de> + PartialEq + Debug, +{ + let bytes = encode_vec(value).expect("encode"); + + let decoded_owned: T = decode_owned(&bytes).expect("decode_owned"); + assert_eq!(value, &decoded_owned, "decode_owned round-trip mismatch"); + + let decoded: T = decode(&bytes).expect("decode"); + assert_eq!(value, &decoded, "decode round-trip mismatch"); +} + +#[test] +fn request_variants_round_trip() { + let mut props = PropertySet::new(); + props.insert("full address", "host.example:3389"); + props.insert("username", "operator"); + + let mut props2 = PropertySet::new(); + props2.insert("full address", "host.example:3389"); + + let requests = [ + Request::Connect { + properties: props, + log_directive: None, + }, + Request::Connect { + properties: props2, + log_directive: Some("ironrdp_connector=trace,debug".to_owned()), + }, + Request::Disconnect, + Request::Status, + Request::QueryProps { filter: None }, + Request::QueryProps { + filter: Some(KeyFilter::Substring("addr".to_owned())), + }, + Request::QueryProps { + filter: Some(KeyFilter::Prefix("Full".to_owned())), + }, + Request::QueryLogs { + substring: Some("error".to_owned()), + last: Some(50), + }, + Request::QueryLogs { + substring: None, + last: None, + }, + Request::Screenshot, + Request::MouseMove { x: 640, y: 480 }, + Request::MouseButton { + button: MouseButton::Right, + pressed: true, + }, + Request::Wheel { + delta: -120, + horizontal: false, + }, + Request::KeyScancode { + scancode: 0x1C, + pressed: false, + }, + Request::KeyUnicode { + ch: '\u{00e9}', + pressed: true, + }, + ]; + + for request in &requests { + round_trip(request); + } +} + +#[test] +fn response_variants_round_trip() { + let responses = [ + Response::ok(), + Response::error("connection refused"), + Response::Ok(Payload::Status(StatusInfo { + state: ConnState::NoSession, + destination: None, + width: None, + height: None, + message: None, + credentials_loaded: true, + })), + Response::Ok(Payload::Status(StatusInfo { + state: ConnState::Connected, + destination: Some("host.example:3389".to_owned()), + width: Some(1920), + height: Some(1080), + message: Some("ok".to_owned()), + credentials_loaded: false, + })), + Response::Ok(Payload::Properties(PropertyDump { + entries: vec![ + PropertyEntry { + key: "full address".to_owned(), + value: PropValue::Str("host.example:3389".to_owned()), + }, + PropertyEntry { + key: "server port".to_owned(), + value: PropValue::Int(3389), + }, + ], + })), + Response::Ok(Payload::Logs(vec!["line one".to_owned(), "line two".to_owned()])), + Response::Ok(Payload::Screenshot { + width: 800, + height: 600, + png: vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A], + }), + Response::Ok(Payload::Empty), + ]; + + for response in &responses { + round_trip(response); + } +} + +#[test] +fn property_set_wire_round_trips() { + let mut original = PropertySet::new(); + original.insert("full address", "host.example:3389"); + original.insert("server port", 3389i64); + original.insert("username", "operator"); + original.insert("screen mode id", 2i64); + + let size = wire::propertyset::size(&original); + let mut buf = vec![0u8; size]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + wire::propertyset::write(&original, &mut cursor).expect("write"); + assert_eq!(cursor.pos(), size, "written length must match computed size"); + + let mut decoded = PropertySet::new(); + let mut read_cursor = ironrdp_core::ReadCursor::new(&buf); + wire::propertyset::read(&mut decoded, &mut read_cursor).expect("read"); + + let mut original_pairs: Vec<_> = original.iter().collect(); + let mut decoded_pairs: Vec<_> = decoded.iter().collect(); + original_pairs.sort_by_key(|(key, _)| *key); + decoded_pairs.sort_by_key(|(key, _)| *key); + assert_eq!(original_pairs, decoded_pairs, "property set wire round-trip mismatch"); +} + +#[test] +fn bytes_wire_round_trips() { + let original = vec![0x89, b'P', b'N', b'G', 0x00, 0xFF, 0x10, 0x20]; + + let size = wire::bytes_size(&original); + let mut buf = vec![0u8; size]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + wire::write_bytes(&mut cursor, &original).expect("write_bytes"); + assert_eq!(cursor.pos(), size, "written length must match computed size"); + + let mut read_cursor = ironrdp_core::ReadCursor::new(&buf); + let decoded = wire::read_bytes(&mut read_cursor).expect("read_bytes"); + assert_eq!(original, decoded, "bytes wire round-trip mismatch"); +} diff --git a/crates/ironrdp-testsuite-extra/tests/main.rs b/crates/ironrdp-testsuite-extra/tests/main.rs index bc775d3cde..dc401db0f7 100644 --- a/crates/ironrdp-testsuite-extra/tests/main.rs +++ b/crates/ironrdp-testsuite-extra/tests/main.rs @@ -1,5 +1,6 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary #![allow(clippy::unwrap_used, reason = "unwrap is fine in tests")] +mod agent; mod client_config; mod e2e; From 2d3bdef1a7167d2acdc478a92917cbb2f018960b Mon Sep 17 00:00:00 2001 From: clintcan Date: Wed, 1 Jul 2026 12:44:43 +0800 Subject: [PATCH 303/325] feat(rdpsnd)!: misuse-resistant format negotiation for RdpsndServerHandler (#1359) Move the negotiation into the crate and split selection from lifecycle: ```rust fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>; fn start(&mut self, format: &NegotiatedFormat); ``` Co-authored-by: Clint Christopher Canada --- Cargo.lock | 1 + crates/ironrdp-rdpsnd/Cargo.toml | 6 + crates/ironrdp-rdpsnd/src/server.rs | 168 ++++++++++-- crates/ironrdp-testsuite-core/Cargo.toml | 2 +- .../tests/rdpsnd/mod.rs | 1 + .../tests/rdpsnd/server.rs | 244 ++++++++++++++++++ crates/ironrdp/examples/server.rs | 44 ++-- 7 files changed, 420 insertions(+), 46 deletions(-) create mode 100644 crates/ironrdp-testsuite-core/tests/rdpsnd/server.rs diff --git a/Cargo.lock b/Cargo.lock index 6f52aa85b6..3f9cfce08e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2852,6 +2852,7 @@ dependencies = [ "ironrdp-pdu", "ironrdp-svc", "tracing", + "visibility", ] [[package]] diff --git a/crates/ironrdp-rdpsnd/Cargo.toml b/crates/ironrdp-rdpsnd/Cargo.toml index 93cabeb7b1..bb5aa59374 100644 --- a/crates/ironrdp-rdpsnd/Cargo.toml +++ b/crates/ironrdp-rdpsnd/Cargo.toml @@ -19,6 +19,11 @@ test = false [features] default = [] std = [] +# Internal (PRIVATE!) feature used to aid testing. +# Don't rely on this whatsoever. It may disappear at any time. +# It uses `visibility` to expose otherwise-private negotiation helpers to the +# integration testsuite (the lib has no inline test harness — `test = false`). +__test = ["dep:visibility"] [dependencies] bitflags = "2.11" @@ -26,6 +31,7 @@ tracing = { version = "0.1", features = ["log"] } ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public +visibility = { version = "0.1", optional = true } [lints] workspace = true diff --git a/crates/ironrdp-rdpsnd/src/server.rs b/crates/ironrdp-rdpsnd/src/server.rs index dd0026deee..6b72bdb5f3 100644 --- a/crates/ironrdp-rdpsnd/src/server.rs +++ b/crates/ironrdp-rdpsnd/src/server.rs @@ -28,34 +28,91 @@ pub enum RdpsndServerMessage { Error(Box), } +/// A server-offered audio format that the client also advertised support for, +/// paired with the `wFormatNo` the client expects for it on the wire. +/// +/// The crate computes the set of these — the intersection of the server's +/// [`get_formats`] and the client's accepted formats — and hands it to +/// [`RdpsndServerHandler::choose_format`], which returns the one to stream. +/// +/// `wformat_no` is intentionally private and there is no public constructor: +/// a handler can neither build nor mutate a `NegotiatedFormat`, so the index +/// stamped onto every Wave/Wave2 PDU is always a valid position in the +/// client's own format list. This makes it impossible to emit an out-of-range +/// `wFormatNo` (which a compliant client rejects, silently dropping all audio +/// — the classic footgun of the old index-returning API). +/// +/// [`get_formats`]: RdpsndServerHandler::get_formats +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegotiatedFormat { + /// The negotiated audio format (common to server and client). + format: pdu::AudioFormat, + /// Position of `format` in the client's Client Audio Formats list — the + /// `wFormatNo` the client resolves each wave against. Crate-owned. + wformat_no: u16, +} + +impl NegotiatedFormat { + /// The negotiated audio format — common to both server and client, and the + /// one the returned wave data should match. + pub fn format(&self) -> &pdu::AudioFormat { + &self.format + } + + /// Test-only accessor for the crate-private `wformat_no`, exposed for the + /// integration testsuite behind the private `__test` feature. Not a stable API. + #[cfg(feature = "__test")] + #[doc(hidden)] + pub fn wformat_no(&self) -> u16 { + self.wformat_no + } +} + /// Handler for the server side of the Audio Output Virtual Channel (`RDPSND`). /// -/// Implementations supply the list of audio formats the server offers, decide -/// which format to use once the client replies, and produce the audio waves to -/// stream (via [`RdpsndServer::wave`]). +/// Implementations supply the list of audio formats the server offers, choose +/// which negotiated format to use once the client replies, and produce the +/// audio waves to stream (via [`RdpsndServer::wave`]). pub trait RdpsndServerHandler: Send + core::fmt::Debug { /// The audio formats the server advertises in the Server Audio Formats and /// Version PDU (MS-RDPEA 2.2.2.1). fn get_formats(&self) -> &[pdu::AudioFormat]; - /// Called once the client has replied with the formats it accepts - /// (`client_format`, the Client Audio Formats and Version PDU). Returns the - /// `wFormatNo` to stamp on every subsequent Wave/Wave2 PDU, or [`None`] if - /// no offered format is acceptable (no audio is then streamed). + /// Select which format to stream, once the client has replied with the + /// formats it accepts. + /// + /// `common` is the set of formats from [`get_formats`] that the client also + /// advertised, in the server's preference order; each carries the + /// `wFormatNo` the client expects, so the crate — not the handler — owns + /// the index arithmetic and the MS-RDPEA rule that `wFormatNo` addresses + /// the *client's* list. `common` is never empty: when server and client + /// share no format, this method is not called and no audio is streamed. /// - /// **The returned index addresses `client_format.formats` — the formats the - /// client just echoed back — NOT the server's own [`get_formats`] list.** - /// The client resolves each wave's format as `ClientFormats[wFormatNo]` - /// against the list *it* sent, and a compliant client rejects any - /// `wFormatNo >= client_format.formats.len()`, silently dropping all audio. - /// The client's list is its accepted subset of the server's formats, so the - /// two lists generally differ in both length and ordering; an index into - /// [`get_formats`] only happens to work when the chosen format sits at the - /// same position in both. Pick the format you intend to send, then return - /// its position within `client_format.formats`. + /// Return the [`NegotiatedFormat`] to stream (a reference borrowed from + /// `common`), or [`None`] to decline. Returning a borrow from `common` + /// — rather than an index or a constructed value — makes it impossible to + /// pick a format the client did not accept or to produce an invalid + /// `wFormatNo`. This is a pure selection step: any encoder/producer setup + /// belongs in [`start`], which the crate calls next with the chosen format. /// /// [`get_formats`]: RdpsndServerHandler::get_formats - fn start(&mut self, client_format: &ClientAudioFormatPdu) -> Option; + /// [`start`]: RdpsndServerHandler::start + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>; + + /// Begin streaming with the `format` just selected by [`choose_format`]. + /// + /// Called once per session, immediately after a successful + /// [`choose_format`]. This is the lifecycle hook: initialize encoder state, + /// spawn the producer, etc. Waves are then emitted via [`RdpsndServer::wave`]. + /// + /// Return `Err` if initialization fails (e.g. the encoder can't be created). + /// The crate then **declines the negotiated format** — exactly as if + /// [`choose_format`] had returned [`None`] — rather than leaving the channel + /// "negotiated" but silently producing no audio. The error is logged by the + /// crate. + /// + /// [`choose_format`]: RdpsndServerHandler::choose_format + fn start(&mut self, format: &NegotiatedFormat) -> Result<(), Box>; /// Called when the audio stream is torn down (e.g. the client closed the /// channel or the session ended). @@ -173,6 +230,52 @@ impl RdpsndServer { } } +/// Build the set of formats common to the server (`server_formats`, kept in the +/// server's preference order) and the client (`client_formats`), each tagged +/// with its `wFormatNo` — its index in the *client's* list, which is what the +/// client resolves waves against (MS-RDPEA). The result mirrors the server's +/// ordering so the handler can express preference simply by `get_formats` +/// order, while the `wFormatNo` always points into the client list. +#[cfg_attr(feature = "__test", visibility::make(pub))] +fn negotiate_formats( + server_formats: &[pdu::AudioFormat], + client_formats: &[pdu::AudioFormat], +) -> Vec { + server_formats + .iter() + .filter_map(|server_format| { + client_formats + .iter() + .position(|client_fmt| audio_format_eq(client_fmt, server_format)) + .and_then(|idx| u16::try_from(idx).ok()) + .map(|wformat_no| NegotiatedFormat { + format: server_format.clone(), + wformat_no, + }) + }) + .collect() +} + +/// Compare two audio formats for negotiation. The WAVEFORMATEX identity fields +/// — wave format tag, channel count, sample rate, bit depth — must match, and so +/// must the codec-specific extra-data blob (`data`). +/// +/// The two derived fields (`n_avg_bytes_per_sec`, `n_block_align`) are +/// deliberately ignored: they are computable from the others and a client may +/// legitimately not echo them back byte-for-byte. The `data` blob is a different +/// category, though — for codecs whose extra-format bytes carry real +/// configuration (AAC's HEAACWAVEINFO extra data is the clear case, MS-RDPEA +/// 2.2.2.1.1's `cbSize` + extra data), ignoring it could match two genuinely +/// incompatible formats, so it IS compared. +#[cfg_attr(feature = "__test", visibility::make(pub))] +fn audio_format_eq(a: &pdu::AudioFormat, b: &pdu::AudioFormat) -> bool { + a.format == b.format + && a.n_channels == b.n_channels + && a.n_samples_per_sec == b.n_samples_per_sec + && a.bits_per_sample == b.bits_per_sample + && a.data == b.data +} + impl_as_any!(RdpsndServer); impl SvcProcessor for RdpsndServer { @@ -220,8 +323,35 @@ impl SvcProcessor for RdpsndServer { return Ok(vec![]); }; let client_format = self.client_format.as_ref().expect("available in this state"); + // Formats common to server and client, in the server's + // preference order, each tagged with its wFormatNo (its + // position in the *client's* list). Keeping this in the crate + // means the handler never does index arithmetic and can't emit + // an out-of-range wFormatNo. + let common = negotiate_formats(self.handler.get_formats(), &client_format.formats); self.state = RdpsndState::Ready; - self.format_no = self.handler.start(client_format); + if common.is_empty() { + debug!("No audio format in common with the client; audio disabled"); + } else if let Some(chosen) = self.handler.choose_format(&common) { + // `chosen` borrows `common` (a local), not `self`, so the + // handler is free to borrow `&mut self` again for `start`. + let wformat_no = chosen.wformat_no; + // Commit the index BEFORE the `start` lifecycle hook: if `start` + // spawns a producer that emits a wave immediately, `wave()` must + // already see a valid `format_no` rather than racing an unset one. + self.format_no = Some(wformat_no); + if let Err(e) = self.handler.start(chosen) { + // Initialization failed (e.g. the encoder couldn't be + // created). Roll back to a cleanly *declined* state — the + // same outcome as `choose_format` returning `None` — instead + // of leaving the channel "negotiated" but silently producing + // no audio. + error!(error = %e, "rdpsnd handler failed to start; declining the negotiated format"); + self.format_no = None; + } + } else { + debug!("Handler declined every common audio format; audio disabled"); + } vec![] } RdpsndState::Ready => { diff --git a/crates/ironrdp-testsuite-core/Cargo.toml b/crates/ironrdp-testsuite-core/Cargo.toml index 969c3b7848..689da1ef34 100644 --- a/crates/ironrdp-testsuite-core/Cargo.toml +++ b/crates/ironrdp-testsuite-core/Cargo.toml @@ -52,7 +52,7 @@ ironrdp-input.path = "../ironrdp-input" ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" ironrdp-rdpdr.path = "../ironrdp-rdpdr" ironrdp-rdpeusb.path = "../ironrdp-rdpeusb" -ironrdp-rdpsnd.path = "../ironrdp-rdpsnd" +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", features = ["__test"] } ironrdp-server.path = "../ironrdp-server" ironrdp-session = { path = "../ironrdp-session", features = ["qoi"] } ironrdp-cfg.path = "../ironrdp-cfg" diff --git a/crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs index 20c2d1623b..64ec4134d5 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs @@ -1,4 +1,5 @@ mod client; +mod server; use std::borrow::Cow; diff --git a/crates/ironrdp-testsuite-core/tests/rdpsnd/server.rs b/crates/ironrdp-testsuite-core/tests/rdpsnd/server.rs new file mode 100644 index 0000000000..fddd202db9 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpsnd/server.rs @@ -0,0 +1,244 @@ +//! Server-side tests for `ironrdp-rdpsnd`. +//! +//! Two layers: +//! - the crate-private `negotiate_formats` / `audio_format_eq` helpers, exposed +//! to this testsuite via the rdpsnd crate's private `__test` feature (the lib +//! itself has no inline test harness — `test = false`); +//! - the `SvcProcessor` negotiation wiring, driven black-box through the public +//! surface (no `__test` shim needed). + +use std::sync::{Arc, Mutex}; + +use ironrdp_core::encode_vec; +use ironrdp_rdpsnd::pdu::{ + AudioFormat, AudioFormatFlags, ClientAudioFormatPdu, ClientAudioOutputPdu, TrainingConfirmPdu, Version, WaveFormat, +}; +use ironrdp_rdpsnd::server::{ + NegotiatedFormat, RdpsndError, RdpsndServer, RdpsndServerHandler, audio_format_eq, negotiate_formats, +}; +use ironrdp_svc::SvcProcessor as _; + +fn fmt(format: WaveFormat, rate: u32) -> AudioFormat { + AudioFormat { + format, + n_channels: 2, + n_samples_per_sec: rate, + n_avg_bytes_per_sec: rate * 4, + n_block_align: 4, + bits_per_sample: 16, + data: None, + } +} + +// ============================================================================ +// `negotiate_formats` / `audio_format_eq` helpers (via the `__test` feature) +// ============================================================================ + +#[test] +fn wformat_no_addresses_the_client_list_not_the_server_list() { + // Server prefers AAC over PCM; the client lists them in the opposite + // order. wFormatNo must follow the CLIENT's indices. + let server = [fmt(WaveFormat::AAC_MS, 44100), fmt(WaveFormat::PCM, 44100)]; + let client = [fmt(WaveFormat::PCM, 44100), fmt(WaveFormat::AAC_MS, 44100)]; + + let common = negotiate_formats(&server, &client); + + // Ordering follows the server's preference (AAC first)... + assert_eq!(common.len(), 2); + assert_eq!(common[0].format().format, WaveFormat::AAC_MS); + assert_eq!(common[1].format().format, WaveFormat::PCM); + // ...but each wFormatNo is the position in the CLIENT list. + assert_eq!(common[0].wformat_no(), 1); // AAC is client index 1 + assert_eq!(common[1].wformat_no(), 0); // PCM is client index 0 +} + +#[test] +fn pcm_only_client_gets_a_valid_client_index() { + // Regression for the --enable-aac trap: server advertises [AAC, PCM] + // but a PCM-only client must get wFormatNo 0 (its sole index), not + // PCM's server-list index of 1 (which the client would reject). + let server = [fmt(WaveFormat::AAC_MS, 44100), fmt(WaveFormat::PCM, 44100)]; + let client = [fmt(WaveFormat::PCM, 44100)]; + + let common = negotiate_formats(&server, &client); + + assert_eq!(common.len(), 1); + assert_eq!(common[0].format().format, WaveFormat::PCM); + assert_eq!(common[0].wformat_no(), 0); +} + +#[test] +fn no_shared_format_yields_empty() { + let server = [fmt(WaveFormat::OPUS, 48000)]; + let client = [fmt(WaveFormat::PCM, 44100)]; + assert!(negotiate_formats(&server, &client).is_empty()); +} + +#[test] +fn equality_ignores_derived_fields_but_not_extra_data() { + let mut a = fmt(WaveFormat::PCM, 44100); + let mut b = fmt(WaveFormat::PCM, 44100); + + // The two derived fields are computable and a client need not echo them — + // differing there is still the same format. + b.n_avg_bytes_per_sec = 0; + b.n_block_align = 99; + assert!(audio_format_eq(&a, &b)); + + // The codec extra-data blob IS significant (e.g. AAC config): a differing + // `data` is a different format, even with identical WAVEFORMATEX fields. + a.data = Some(vec![1, 2, 3]); + b.data = None; + assert!(!audio_format_eq(&a, &b)); + + // A differing identity field (sample rate) is a different format. + let c = fmt(WaveFormat::PCM, 48000); + assert!(!audio_format_eq(&a, &c)); +} + +#[test] +fn extra_data_must_match_for_otherwise_identical_formats() { + // Two AAC formats identical in every WAVEFORMATEX field but carrying + // different HEAACWAVEINFO extra data are genuinely incompatible and must + // not be treated as a match (the MS-RDPEA 2.2.2.1.1 `data` case). + let mut server = fmt(WaveFormat::AAC_MS, 44100); + server.data = Some(vec![0x11, 0x90]); + let mut client = fmt(WaveFormat::AAC_MS, 44100); + client.data = Some(vec![0x12, 0x08]); + + assert!(negotiate_formats(&[server], &[client]).is_empty()); +} + +// ============================================================================ +// `SvcProcessor` negotiation wiring (black-box, public surface only) +// ============================================================================ + +#[derive(Debug, Default)] +struct Recording { + choose_format_calls: usize, + start_calls: usize, + chosen_wformat: Option, +} + +#[derive(Debug)] +struct FakeHandler { + formats: Vec, + rec: Arc>, + start_ok: bool, +} + +impl RdpsndServerHandler for FakeHandler { + fn get_formats(&self) -> &[AudioFormat] { + &self.formats + } + + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat> { + let mut rec = self.rec.lock().expect("poisoned"); + rec.choose_format_calls += 1; + let chosen = common.first(); + rec.chosen_wformat = chosen.map(NegotiatedFormat::wformat_no); + chosen + } + + fn start(&mut self, _format: &NegotiatedFormat) -> Result<(), Box> { + self.rec.lock().expect("poisoned").start_calls += 1; + if self.start_ok { + Ok(()) + } else { + Err(Box::new(std::io::Error::other("simulated init failure"))) + } + } + + fn stop(&mut self) {} +} + +/// Drive a fresh server through the handshake (server announce → client formats +/// → training confirm) so the negotiation (`choose_format` + `start`) runs. +/// Client version is V5 (< V6) to skip the optional Quality Mode step. +fn drive_to_ready(server: &mut RdpsndServer, client_formats: Vec) { + server.start().expect("server announce"); + + let client_af = ClientAudioOutputPdu::AudioFormat(ClientAudioFormatPdu { + version: Version::V5, + flags: AudioFormatFlags::empty(), + formats: client_formats, + volume_left: 0, + volume_right: 0, + pitch: 0, + dgram_port: 0, + }); + server + .process(&encode_vec(&client_af).expect("encode client formats")) + .expect("process client formats"); + + let confirm = ClientAudioOutputPdu::TrainingConfirm(TrainingConfirmPdu { + timestamp: 0, + pack_size: 0, + }); + server + .process(&encode_vec(&confirm).expect("encode training confirm")) + .expect("process training confirm"); +} + +#[test] +fn processor_skips_choose_format_when_nothing_in_common() { + let rec = Arc::new(Mutex::new(Recording::default())); + let mut server = RdpsndServer::new(Box::new(FakeHandler { + formats: vec![fmt(WaveFormat::PCM, 44100)], + rec: Arc::clone(&rec), + start_ok: true, + })); + + // Server offers only PCM; client offers only AAC → no common format. + drive_to_ready(&mut server, vec![fmt(WaveFormat::AAC_MS, 44100)]); + + { + let rec = rec.lock().expect("poisoned"); + assert_eq!( + rec.choose_format_calls, 0, + "choose_format must be skipped when common is empty" + ); + assert_eq!(rec.start_calls, 0); + } + // Nothing negotiated → no format committed. + assert!(server.wave(vec![0; 4], 0).is_err()); +} + +#[test] +fn processor_calls_start_once_and_streams_on_success() { + let rec = Arc::new(Mutex::new(Recording::default())); + let mut server = RdpsndServer::new(Box::new(FakeHandler { + formats: vec![fmt(WaveFormat::PCM, 44100)], + rec: Arc::clone(&rec), + start_ok: true, + })); + + drive_to_ready(&mut server, vec![fmt(WaveFormat::PCM, 44100)]); + + { + let rec = rec.lock().expect("poisoned"); + assert_eq!(rec.choose_format_calls, 1); + assert_eq!(rec.start_calls, 1, "start must be called exactly once"); + assert_eq!(rec.chosen_wformat, Some(0)); // PCM is the client's only entry + } + // Format committed → waves stream. + assert!(server.wave(vec![0; 4], 0).is_ok()); +} + +#[test] +fn processor_declines_when_start_fails() { + let rec = Arc::new(Mutex::new(Recording::default())); + let mut server = RdpsndServer::new(Box::new(FakeHandler { + formats: vec![fmt(WaveFormat::PCM, 44100)], + rec: Arc::clone(&rec), + start_ok: false, // simulate an encoder/init failure + })); + + drive_to_ready(&mut server, vec![fmt(WaveFormat::PCM, 44100)]); + + assert_eq!(rec.lock().expect("poisoned").start_calls, 1); + // `start` returned Err → the crate rolls `format_no` back to None and + // declines, so no audio is streamed — rather than a silent + // "negotiated, no audio" state with a committed format and no producer. + assert!(server.wave(vec![0; 4], 0).is_err()); +} diff --git a/crates/ironrdp/examples/server.rs b/crates/ironrdp/examples/server.rs index 71db6d021d..ef5d44028f 100644 --- a/crates/ironrdp/examples/server.rs +++ b/crates/ironrdp/examples/server.rs @@ -5,14 +5,15 @@ use core::net::SocketAddr; use core::num::{NonZeroU16, NonZeroUsize}; +use std::io; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use anyhow::Context as _; use ironrdp::cliprdr::backend::{CliprdrBackend, CliprdrBackendFactory}; use ironrdp::connector::DesktopSize; -use ironrdp::rdpsnd::pdu::{AudioFormat, ClientAudioFormatPdu, WaveFormat}; -use ironrdp::rdpsnd::server::{RdpsndServerHandler, RdpsndServerMessage}; +use ironrdp::rdpsnd::pdu::{AudioFormat, WaveFormat}; +use ironrdp::rdpsnd::server::{NegotiatedFormat, RdpsndError, RdpsndServerHandler, RdpsndServerMessage}; use ironrdp::server::tokio::sync::mpsc::UnboundedSender; use ironrdp::server::tokio::time::{self, Duration, sleep}; use ironrdp::server::{ @@ -255,17 +256,6 @@ struct SndHandler { task: Option>, } -impl SndHandler { - fn choose_format(&self, client_formats: &[AudioFormat]) -> Option { - for (n, fmt) in client_formats.iter().enumerate() { - if self.get_formats().contains(fmt) { - return u16::try_from(n).ok(); - } - } - None - } -} - impl RdpsndServerHandler for SndHandler { fn get_formats(&self) -> &[AudioFormat] { &[ @@ -290,30 +280,32 @@ impl RdpsndServerHandler for SndHandler { ] } - fn start(&mut self, client_format: &ClientAudioFormatPdu) -> Option { - debug!(?client_format); + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat> { + debug!(?common); - let Some(nfmt) = self.choose_format(&client_format.formats) else { - return Some(0); - }; + // The crate hands us the formats common to both peers in our preference + // order; take the most-preferred one. + common.first() + } - let fmt = client_format.formats[usize::from(nfmt)].clone(); + fn start(&mut self, format: &NegotiatedFormat) -> Result<(), Box> { + let fmt = format.format().clone(); let mut opus_enc = if fmt.format == WaveFormat::OPUS { let n_channels: opus2::Channels = match fmt.n_channels { 1 => opus2::Channels::Mono, 2 => opus2::Channels::Stereo, - n => { - warn!("Invalid OPUS channels: {}", n); - return Some(0); - } + // Init failure: decline the format instead of leaving the channel + // negotiated-but-silent (the crate logs the error and skips audio). + n => return Err(Box::new(io::Error::other(format!("invalid OPUS channels: {n}")))), }; match opus2::Encoder::new(fmt.n_samples_per_sec, n_channels, opus2::Application::Audio) { Ok(enc) => Some(enc), Err(err) => { - warn!("Failed to create OPUS encoder: {}", err); - return Some(0); + return Err(Box::new(io::Error::other(format!( + "failed to create OPUS encoder: {err}" + )))); } } } else { @@ -349,7 +341,7 @@ impl RdpsndServerHandler for SndHandler { } })); - Some(nfmt) + Ok(()) } fn stop(&mut self) { From 368fe8e68b2d5d72da2e15dcf99469b98e965a2b Mon Sep 17 00:00:00 2001 From: Rocco De Angelis Date: Wed, 1 Jul 2026 10:33:39 +0100 Subject: [PATCH 304/325] fix(graphics): don't require CONTEXT block on every progressive frame (#1395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes progressive RemoteFX (MS-RDPEGFX) decoding by no longer requiring a CONTEXT block on every WireToSurface2 progressive frame once a codec context has already been established (keyed by codec_context_id). This aligns the decoder with real-world server behavior and the spec’s “establish once, then reference” model for progressive contexts. --- crates/ironrdp-graphics/src/progressive.rs | 34 +++++++++++++++------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/ironrdp-graphics/src/progressive.rs b/crates/ironrdp-graphics/src/progressive.rs index 315b8aa96f..a6a4273f1a 100644 --- a/crates/ironrdp-graphics/src/progressive.rs +++ b/crates/ironrdp-graphics/src/progressive.rs @@ -1076,16 +1076,30 @@ impl ProgressiveDecoder { let blocks = decode_progressive_stream(bitmap_data)?; - // Extract context flags from the CONTEXT block. Per MS-RDPEGFX 2.2.4.2 - // a Progressive stream MUST begin with SYNC + CONTEXT; treat absence as - // a malformed stream rather than silently defaulting band layout. - let use_reduce_extrapolate = blocks - .iter() - .find_map(|block| match block { - ProgressiveBlock::Context(ctx) => Some(ctx.uses_reduce_extrapolate()), - _ => None, - }) - .ok_or(ProgressiveDecodeError::MissingBlock("CONTEXT"))?; + // Extract the band-layout flag from the CONTEXT block when present. + // Per MS-RDPEGFX 2.2.4.2 the SYNC + CONTEXT blocks establish a codec + // context once (keyed by `codec_context_id`) and are not required to be + // repeated on subsequent frames that reference the same context. + // Real-world servers (xrdp, GNOME Remote Desktop) omit the CONTEXT + // block on every frame after the first one that established the + // context. The strict requirement rejected each of those frames with + // `MissingBlock("CONTEXT")`, freezing the image on the coarse first + // pass. + // + // Fall back to the value stored when the context was first created. + // Only error when neither source is available, i.e. the very first + // frame for a context arrived without a CONTEXT block. + let use_reduce_extrapolate = match blocks.iter().find_map(|block| match block { + ProgressiveBlock::Context(ctx) => Some(ctx.uses_reduce_extrapolate()), + _ => None, + }) { + Some(v) => v, + None => self + .contexts + .get(&codec_context_id) + .map(|c| c.surface.use_reduce_extrapolate) + .ok_or(ProgressiveDecodeError::MissingBlock("CONTEXT"))?, + }; // Get or create the context for this codec_context_id let context = match self.contexts.entry(codec_context_id) { From 38980fc0eb66bb79a8655ab8ceb59a93ecd5c17e Mon Sep 17 00:00:00 2001 From: uchouT Date: Wed, 1 Jul 2026 21:36:26 +0800 Subject: [PATCH 305/325] feat(rdpeusb): implement urbdrc server processors (#1394) Adds the server-side RDPEUSB (URBDRC) protocol processors to ironrdp-rdpeusb, introduces a backend-facing rdpeusb::io data model shared by client/server, and refactors TS_URB representations to better separate headers from payload variants. --- .../src/{client/mod.rs => client.rs} | 135 ++-- .../src/{client => io}/device.rs | 14 + crates/ironrdp-rdpeusb/src/io/mod.rs | 360 +++++++++ crates/ironrdp-rdpeusb/src/lib.rs | 2 + .../ironrdp-rdpeusb/src/pdu/completion/mod.rs | 6 +- .../src/pdu/iface_manipulation.rs | 2 + crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs | 26 +- .../src/pdu/usb_dev/ts_urb/mod.rs | 763 ++++++++---------- .../src/pdu/usb_dev/ts_urb/utils.rs | 4 +- crates/ironrdp-rdpeusb/src/pdu/utils.rs | 12 +- crates/ironrdp-rdpeusb/src/server.rs | 659 +++++++++++++++ .../tests/rdpeusb/client.rs | 31 +- .../tests/rdpeusb/device.rs | 2 +- .../tests/rdpeusb/mod.rs | 2 +- 14 files changed, 1490 insertions(+), 528 deletions(-) rename crates/ironrdp-rdpeusb/src/{client/mod.rs => client.rs} (91%) rename crates/ironrdp-rdpeusb/src/{client => io}/device.rs (94%) create mode 100644 crates/ironrdp-rdpeusb/src/io/mod.rs create mode 100644 crates/ironrdp-rdpeusb/src/server.rs diff --git a/crates/ironrdp-rdpeusb/src/client/mod.rs b/crates/ironrdp-rdpeusb/src/client.rs similarity index 91% rename from crates/ironrdp-rdpeusb/src/client/mod.rs rename to crates/ironrdp-rdpeusb/src/client.rs index c4f5ca3886..6a354f91ab 100644 --- a/crates/ironrdp-rdpeusb/src/client/mod.rs +++ b/crates/ironrdp-rdpeusb/src/client.rs @@ -1,20 +1,22 @@ use alloc::collections::btree_map::{BTreeMap, Entry}; -use alloc::string::String; use alloc::vec; use alloc::{boxed::Box, vec::Vec}; use ironrdp_core::{Decode as _, ReadCursor, impl_as_any}; use ironrdp_dvc::{DvcChannelListener, DvcClientProcessor, DvcMessage, DvcProcessor}; use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; +use crate::io::device::add_device_from_info; +use crate::io::{ + DeviceText, InternalIoControlPacket, IoControlCompletionResult, IoControlPacket, TransferInCompletionResult, + TransferInPacket, TransferOutCompletionResult, TransferOutPacket, device::DeviceInfo, +}; use crate::pdu::UrbdrcServerDevicePdu; -use crate::pdu::completion::ts_urb_result::TsUrbResult; use crate::pdu::completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}; use crate::pdu::header::{InterfaceId, Mask, MessageId}; use crate::pdu::iface_manipulation::{InterfaceRelease, QueryInterfaceFailureResponse}; use crate::pdu::sink::AddVirtualChannel; -use crate::pdu::usb_dev::ts_urb::TsUrbOut; -use crate::pdu::usb_dev::{InternalIoControl, IoControl, QueryDeviceTextRsp, TransferInRequest, TransferOutRequest}; -use crate::pdu::utils::{HResult, RequestId, RequestIdTransferInOut}; +use crate::pdu::usb_dev::QueryDeviceTextRsp; +use crate::pdu::utils::{RequestId, RequestIdTransferInOut}; use crate::pdu::{ UrbdrcServerControlPdu, caps::{Capability, RimExchangeCapabilityResponse}, @@ -22,9 +24,6 @@ use crate::pdu::{ }; use crate::{CHANNEL_NAME, InvalidDeviceInterfaceId}; -pub mod device; -pub use device::*; - const ADD_VIRTUAL_CHANNEL_MSG_ID: u32 = 0; pub trait DeviceManagerBackend: Send { @@ -197,6 +196,7 @@ impl DvcClientProcessor for UrbdrcControlClient {} pub trait UrbdrcDeviceBackend: Send { /// Get the USB device information. fn device_info(&mut self, channel_id: u32) -> PduResult; + /// [Processing a Cancel Request Message][3.3.5.3.1]: /// /// The client MUST attempt to stop processing the request identified by the RequestId field in @@ -206,6 +206,7 @@ pub trait UrbdrcDeviceBackend: Send { /// /// [3.3.5.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d5315234-d9ba-42dc-bc1b-b421c57a21ae fn cancel_request(&mut self, request_id: RequestId, channel_id: u32); + /// [Processing a Query Device Text Message][3.3.5.3.5]: /// /// After receiving the QUERY_DEVICE_TEXT message, the client forwards the request to the @@ -215,42 +216,55 @@ pub trait UrbdrcDeviceBackend: Send { /// /// [3.3.5.3.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/834f56cc-cfed-4649-8952-0b6486638c28 fn query_device_text(&mut self, channel_id: u32, text_type: u32, locale_id: u32) -> PduResult>; - /// Process an [`IoControl`] request. + + /// Process an `IoControl` request. /// /// Returning [`None`] means the request remains pending and no immediate completion is sent. fn io_control( &mut self, channel_id: u32, request_id: RequestId, - request: IoControl, - ) -> PduResult>; - /// Process an [`InternalIoControl`] request. + request: IoControlPacket, + ) -> PduResult>; + + /// Process an `InternalIoControl` request. /// /// Returning [`None`] means the request remains pending and no immediate completion is sent. fn internal_io_control( &mut self, channel_id: u32, request_id: RequestId, - request: InternalIoControl, - ) -> PduResult>; - /// Process a [`TransferInRequest`]. + request: InternalIoControlPacket, + ) -> PduResult>; + + /// Process a `TransferInRequest`. /// /// Returning [`None`] means the request remains pending and no immediate completion is sent. fn transfer_in( &mut self, channel_id: u32, request_id: RequestId, - request: TransferInRequest, - ) -> PduResult>; - /// Process a [`TransferOutRequest`]. + request: TransferInPacket, + ) -> PduResult>; + + /// Process a `TransferOutRequest`. /// /// Returning [`None`] means the request remains pending and no immediate completion is sent. fn transfer_out( &mut self, channel_id: u32, request_id: RequestId, - request: TransferOutRequest, - ) -> PduResult>; + request: TransferOutPacket, + ) -> PduResult>; + + /// Process a no_ack `TransferOutRequest`. + fn transfer_out_no_ack( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + ) -> PduResult<()>; + /// [Processing a Retract Device Message][3.3.5.3.8]: /// /// After receiving the RETRACT_DEVICE message, the client SHOULD terminate the dynamic channel @@ -260,33 +274,6 @@ pub trait UrbdrcDeviceBackend: Send { fn retract(&mut self, channel_id: u32) -> PduResult<()>; } -#[derive(Debug, Clone)] -pub struct DeviceText { - pub hresult: u32, - pub description: String, -} - -#[derive(Debug, Clone)] -pub struct IoControlResponse { - pub hresult: HResult, - pub information: u32, - pub output_buffer: Vec, -} - -#[derive(Debug, Clone)] -pub struct UrbInResponse { - pub ts_urb_result: TsUrbResult, - pub hresult: HResult, - pub output_buffer: Vec, -} - -#[derive(Debug, Clone)] -pub struct UrbOutResponse { - pub ts_urb_result: TsUrbResult, - pub hresult: HResult, - pub output_buffer_size: u32, -} - /// A client for the URBDRC Device Virtual Channel. pub struct UrbdrcDeviceClient { /// Indicates whether the channel is ready for handling IO request. @@ -340,7 +327,11 @@ impl UrbdrcDeviceClient { Ok((completion_iface, entry)) } - pub fn io_ctl_completion(&mut self, request_id: RequestId, response: IoControlResponse) -> PduResult { + pub fn io_ctl_completion( + &mut self, + request_id: RequestId, + response: IoControlCompletionResult, + ) -> PduResult { let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; let (msg_id, max_output_buf_size) = match entry.get() { Pending::IoCtl { @@ -367,7 +358,7 @@ impl UrbdrcDeviceClient { pub fn internal_io_ctl_completion( &mut self, request_id: RequestId, - response: IoControlResponse, + response: IoControlCompletionResult, ) -> PduResult { let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; let (msg_id, max_output_buf_size) = match entry.get() { @@ -392,7 +383,11 @@ impl UrbdrcDeviceClient { })) } - pub fn transfer_in_completion(&mut self, request_id: RequestId, response: UrbInResponse) -> PduResult { + pub fn transfer_in_completion( + &mut self, + request_id: RequestId, + response: TransferInCompletionResult, + ) -> PduResult { let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; let (msg_id, max_output_buf_size) = match entry.get() { Pending::TransferIn { @@ -436,7 +431,7 @@ impl UrbdrcDeviceClient { pub fn transfer_out_completion( &mut self, request_id: RequestId, - response: UrbOutResponse, + response: TransferOutCompletionResult, ) -> PduResult { let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; let (msg_id, max_output_buf_size) = match entry.get() { @@ -582,7 +577,9 @@ impl DvcProcessor for UrbdrcDeviceClient { let Some(completion_iface) = self.request_completion else { return Ok(Vec::new()); }; - if let Some(io_ctl_response) = self.backend.io_control(channel_id, request_id, io_ctl_pdu)? { + + let io_ctl_packet = io_ctl_pdu.into(); + if let Some(io_ctl_response) = self.backend.io_control(channel_id, request_id, io_ctl_packet)? { let output_buffer_size = check_output_buffer_size(io_ctl_response.output_buffer.len(), max_output_buf_size)?; Ok(vec![Box::new(IoControlCompletion { @@ -618,9 +615,11 @@ impl DvcProcessor for UrbdrcDeviceClient { let Some(completion_iface) = self.request_completion else { return Ok(Vec::new()); }; + + let internal_io_ctl_packet = internal_io_ctl_pdu.into(); if let Some(internal_io_ctl_response) = self.backend - .internal_io_control(channel_id, request_id, internal_io_ctl_pdu)? + .internal_io_control(channel_id, request_id, internal_io_ctl_packet)? { let output_buffer_size = check_output_buffer_size(internal_io_ctl_response.output_buffer.len(), max_output_buf_size)?; @@ -657,10 +656,13 @@ impl DvcProcessor for UrbdrcDeviceClient { let Some(completion_iface) = self.request_completion else { return Ok(Vec::new()); }; - if let Some(urb_response) = self - .backend - .transfer_in(channel_id, request_id.into(), transfer_in_pdu)? - { + + let transfer_in = TransferInPacket { + ts_urb: transfer_in_pdu.ts_urb.into(), + output_buffer_size: transfer_in_pdu.output_buffer_size, + }; + + if let Some(urb_response) = self.backend.transfer_in(channel_id, request_id.into(), transfer_in)? { let output_buffer_size = check_output_buffer_size(urb_response.output_buffer.len(), max_output_buf_size)?; if urb_response.output_buffer.is_empty() { @@ -700,29 +702,26 @@ impl DvcProcessor for UrbdrcDeviceClient { let msg_id = transfer_out_pdu.msg_id; let output_buffer_size = u32::try_from(transfer_out_pdu.output_buffer.len()) .map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; - let (request_id, no_ack) = match &transfer_out_pdu.ts_urb { - TsUrbOut::CtlTransfer(urb) => (urb.header.req_id, urb.header.no_ack), - TsUrbOut::BulkInterruptTransfer(urb) => (urb.header.req_id, urb.header.no_ack), - TsUrbOut::IsochTransfer(urb) => (urb.header.req_id, urb.header.no_ack), - TsUrbOut::CtlDescReq(urb) => (urb.header.req_id, urb.header.no_ack), - TsUrbOut::VendorClassReq(urb) => (urb.header.req_id, urb.header.no_ack), - TsUrbOut::CtlTransferEx(urb) => (urb.header.req_id, urb.header.no_ack), - }; + let request_id = transfer_out_pdu.ts_urb.header.req_id; + let no_ack = transfer_out_pdu.ts_urb.header.no_ack; if self.pending_io.contains_key(&request_id.into()) { return Ok(Vec::new()); } + let transfer_out = TransferOutPacket { + ts_urb: transfer_out_pdu.ts_urb.into(), + output_buffer: transfer_out_pdu.output_buffer, + }; if no_ack { self.backend - .transfer_out(channel_id, request_id.into(), transfer_out_pdu)?; + .transfer_out_no_ack(channel_id, request_id.into(), transfer_out)?; Ok(Vec::new()) } else { let Some(completion_iface) = self.request_completion else { return Ok(Vec::new()); }; if let Some(urb_response) = - self.backend - .transfer_out(channel_id, request_id.into(), transfer_out_pdu)? + self.backend.transfer_out(channel_id, request_id.into(), transfer_out)? { if urb_response.output_buffer_size > output_buffer_size { return Err(pdu_other_err!("output buffer exceeds maximum amount")); diff --git a/crates/ironrdp-rdpeusb/src/client/device.rs b/crates/ironrdp-rdpeusb/src/io/device.rs similarity index 94% rename from crates/ironrdp-rdpeusb/src/client/device.rs rename to crates/ironrdp-rdpeusb/src/io/device.rs index 15e36bfd6b..ef986ed346 100644 --- a/crates/ironrdp-rdpeusb/src/client/device.rs +++ b/crates/ironrdp-rdpeusb/src/io/device.rs @@ -48,6 +48,13 @@ const USB_CLASS_MISCELLANEOUS: u8 = 0xef; const USB_SUBCLASS_COMMON: u8 = 0x02; const USB_PROTOCOL_INTERFACE_ASSOCIATION: u8 = 0x01; +/// USB device facts supplied by a client backend. +/// +/// [`UrbdrcDeviceBackend::device_info`] returns this backend-neutral description. The RDPEUSB +/// client uses it to construct the Windows Plug and Play identifiers and capabilities carried by +/// `ADD_DEVICE`. +/// +/// [`UrbdrcDeviceBackend::device_info`]: crate::client::UrbdrcDeviceBackend::device_info #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeviceInfo { /// Physical/topological location. Used to derive stable Windows PnP instance/container IDs. @@ -96,6 +103,7 @@ impl DeviceInfo { } } +/// Physical location of a USB device in the backend's USB topology. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UsbDeviceLocation { pub bus_number: u8, @@ -115,6 +123,7 @@ impl UsbDeviceLocation { } } +/// Fields from a standard USB device descriptor needed for device announcement. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UsbDeviceDescriptorInfo { pub vendor_id: u16, @@ -125,16 +134,19 @@ pub struct UsbDeviceDescriptorInfo { pub num_configurations: u8, } +/// Information from the device's active USB configuration. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UsbConfigInfo { pub interfaces: Vec, } +/// Information from a USB interface descriptor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UsbInterfaceInfo { pub class_codes: UsbClassCodes, } +/// USB class, subclass, and protocol code triplet. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UsbClassCodes { pub class_code: u8, @@ -157,6 +169,7 @@ impl UsbClassCodes { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UsbBcdVersion(u16); impl UsbBcdVersion { + /// Wraps a raw `bcdUSB` value without validating its BCD digits. pub const fn from_bcd(value: u16) -> Self { Self(value) } @@ -176,6 +189,7 @@ impl UsbBcdVersion { } } +/// Connection speed reported by the USB backend. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UsbConnectionSpeed { Unknown, diff --git a/crates/ironrdp-rdpeusb/src/io/mod.rs b/crates/ironrdp-rdpeusb/src/io/mod.rs new file mode 100644 index 0000000000..8194c99824 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/io/mod.rs @@ -0,0 +1,360 @@ +//! Backend-facing USB I/O types. +//! +//! This module is the data-model boundary between the RDPEUSB state machines and USB backend +//! implementations. The types intentionally omit RDPEUSB routing fields such as message, +//! interface, and request IDs when the state machine can manage those fields itself. +//! +//! On the client side, [`UrbdrcDeviceBackend`] receives the `*Packet` request types and returns +//! the corresponding `*CompletionResult`. Returning `None` from an I/O method leaves the request +//! pending; the backend can later pass a completion and its `RequestId` to the matching +//! completion method on [`UrbdrcDeviceClient`]. +//! +//! On the server side, methods on [`UrbdrcDeviceServer`] accept the `*Packet` request types and +//! return a [`ServerIoRequest`] ready for the DVC transport. Completion results received from the +//! client are delivered to [`UrbdrcDeviceServerBackend`]. +//! +//! `TransferIn` and `TransferOut` are named from the USB device's perspective: an IN transfer +//! reads data from the device, while an OUT transfer writes data to it. +//! +//! [`UrbdrcDeviceBackend`]: crate::client::UrbdrcDeviceBackend +//! [`UrbdrcDeviceClient`]: crate::client::UrbdrcDeviceClient +//! [`UrbdrcDeviceServer`]: crate::server::UrbdrcDeviceServer +//! [`UrbdrcDeviceServerBackend`]: crate::server::UrbdrcDeviceServerBackend + +use alloc::{string::String, vec::Vec}; +use ironrdp_dvc::DvcMessage; +use ironrdp_pdu::{PduError, PduResult, pdu_other_err}; + +pub use crate::pdu::{ + completion::ts_urb_result::TsUrbResult, + sink::UsbDeviceCaps, + usb_dev::{ + InternalIoControl, IoControl, IoctlInternalUsb, UsbInternalIoctlCode, UsbRetractReason, + ts_urb::{TsUrbInKind, TsUrbOutKind, utils::UrbFunction}, + }, + utils::{HResult, RequestId}, +}; +use crate::pdu::{ + header::{InterfaceId, MessageId}, + sink::{AddDevice, NoAckIsochWriteJitterBufSizeInMs}, + usb_dev::ts_urb::{TsUrbIn, TsUrbOut, utils::TsUrbHeader}, +}; + +pub mod device; +pub use device::DeviceInfo; + +/// Result of a device-text query. +/// +/// A client backend returns this from [`UrbdrcDeviceBackend::query_device_text`]. A server backend +/// receives the decoded response through [`UrbdrcDeviceServerBackend::device_text`]. +/// +/// [`UrbdrcDeviceBackend::query_device_text`]: crate::client::UrbdrcDeviceBackend::query_device_text +/// [`UrbdrcDeviceServerBackend::device_text`]: crate::server::UrbdrcDeviceServerBackend::device_text +#[derive(Debug, Clone)] +pub struct DeviceText { + pub hresult: u32, + pub description: String, +} + +/// Completion of an I/O control request. +/// +/// This completes either an [`IoControlPacket`] or an [`InternalIoControlPacket`]. The request ID +/// is carried separately by the backend and completion APIs. +#[derive(Debug, Clone)] +pub struct IoControlCompletionResult { + pub hresult: HResult, + /// Number of bytes transferred, or the required buffer size for an insufficient-buffer result. + /// + /// On success, this must equal `output_buffer.len()`. For other failures, except an + /// insufficient-buffer result, this value is ignored by the peer. + pub information: u32, + /// Data produced by the request. + /// + /// Its length must not exceed the request's [`IoControlPacket::output_buffer_size`] or + /// [`InternalIoControlPacket::output_buffer_size`]. For failures other than an + /// insufficient-buffer result, this must be empty. + pub output_buffer: Vec, +} + +/// Completion of a USB IN transfer. +#[derive(Debug, Clone)] +pub struct TransferInCompletionResult { + /// USB request-block result, including the USBD status and any operation-specific result. + pub ts_urb_result: TsUrbResult, + /// HRESULT returned by the transfer operation. + pub hresult: HResult, + /// Data read from the USB device. + /// + /// Its length must not exceed [`TransferInPacket::output_buffer_size`]. An empty buffer is + /// encoded as `URB_COMPLETION_NO_DATA`. + pub output_buffer: Vec, +} + +/// Completion of a USB OUT transfer. +/// +/// This is used only when [`TsUrbOutPacket::no_ack`] is `false`. +#[derive(Debug, Clone)] +pub struct TransferOutCompletionResult { + /// USB request-block result, including the USBD status and any operation-specific result. + pub ts_urb_result: TsUrbResult, + /// HRESULT returned by the transfer operation. + pub hresult: HResult, + /// Number of bytes written to the USB device. + /// + /// This must not exceed the length of [`TransferOutPacket::output_buffer`]. + pub output_buffer_size: u32, +} + +/// Backend-facing form of an RDPEUSB `IO_CONTROL` request. +#[derive(Debug, Clone)] +pub struct IoControlPacket { + /// Operation to perform on the USB device or its upstream port. + pub ioctl_code: IoctlInternalUsb, + /// Raw input supplied to the operation. + pub input_buffer: Vec, + /// Maximum number of bytes that may be returned in the completion's output buffer. + pub output_buffer_size: u32, +} + +impl From for IoControlPacket { + fn from(value: IoControl) -> Self { + Self { + ioctl_code: value.ioctl_code, + input_buffer: value.input_buffer, + output_buffer_size: value.output_buffer_size, + } + } +} + +impl IoControlPacket { + pub(crate) fn into_pdu(self, msg_id: MessageId, req_id: RequestId, udev_iface: InterfaceId) -> IoControl { + IoControl { + msg_id, + udev_iface, + ioctl_code: self.ioctl_code, + input_buffer: self.input_buffer, + output_buffer_size: self.output_buffer_size, + req_id, + } + } +} + +/// Backend-facing form of an RDPEUSB `INTERNAL_IO_CONTROL` request. +#[derive(Debug, Clone)] +pub struct InternalIoControlPacket { + /// RDPEUSB-defined internal operation to perform. + pub ioctl_code: UsbInternalIoctlCode, + /// Raw input supplied to the operation. + pub input_buffer: Vec, + /// Maximum number of bytes that may be returned in the completion's output buffer. + pub output_buffer_size: u32, +} + +impl InternalIoControlPacket { + pub(crate) fn into_pdu(self, msg_id: MessageId, req_id: RequestId, udev_iface: InterfaceId) -> InternalIoControl { + InternalIoControl { + msg_id, + udev_iface, + ioctl_code: self.ioctl_code, + input_buffer: self.input_buffer, + output_buffer_size: self.output_buffer_size, + req_id, + } + } +} + +impl From for InternalIoControlPacket { + fn from(value: InternalIoControl) -> Self { + Self { + ioctl_code: value.ioctl_code, + input_buffer: value.input_buffer, + output_buffer_size: value.output_buffer_size, + } + } +} + +/// Backend-facing form of a USB IN transfer request. +/// +/// An IN transfer requests data from the USB device. +#[derive(Debug, Clone)] +pub struct TransferInPacket { + /// USB request block describing the operation. + pub ts_urb: TsUrbInPacket, + /// Maximum number of bytes requested from the USB device. + pub output_buffer_size: u32, +} + +/// USB request block carried by a [`TransferInPacket`]. +#[derive(Debug, Clone)] +pub struct TsUrbInPacket { + /// Operation-specific TS_URB payload. + pub kind: TsUrbInKind, + /// URB function code identifying `kind`. + /// + /// The function and payload variant must match; conversion to a wire PDU rejects a mismatch. + pub func: UrbFunction, +} + +impl TsUrbInPacket { + pub(crate) fn into_ts_urb(self, request_id: u32) -> PduResult { + if !self.kind.matches_func(self.func) { + return Err(pdu_other_err!("URB function does not match TS_URB payload")); + } + + let ts_urb_size = self.kind.ts_urb_size()?; + Ok(TsUrbIn { + kind: self.kind, + header: TsUrbHeader { + ts_urb_size, + func: self.func, + req_id: request_id + .try_into() + .map_err(|_| pdu_other_err!("invalid transfer request id"))?, + no_ack: false, + }, + }) + } +} + +impl From for TsUrbInPacket { + fn from(value: TsUrbIn) -> Self { + Self { + kind: value.kind, + func: value.header.func, + } + } +} + +/// Backend-facing form of a USB OUT transfer request. +/// +/// An OUT transfer submits `output_buffer` to the USB device. +#[derive(Debug, Clone)] +pub struct TransferOutPacket { + /// USB request block describing the operation. + pub ts_urb: TsUrbOutPacket, + /// Raw data to write to the USB device. + pub output_buffer: Vec, +} + +/// USB request block carried by a [`TransferOutPacket`]. +#[derive(Debug, Clone)] +pub struct TsUrbOutPacket { + /// Operation-specific TS_URB payload. + pub kind: TsUrbOutKind, + /// Whether the client must omit the completion for this request. + /// + /// RDPEUSB permits this only for isochronous OUT transfers when the device advertised a + /// nonzero no-ack isochronous jitter buffer size. + pub no_ack: bool, + /// URB function code identifying `kind`. + /// + /// The function and payload variant must match; conversion to a wire PDU rejects a mismatch. + pub func: UrbFunction, +} + +impl From for TsUrbOutPacket { + fn from(value: TsUrbOut) -> Self { + Self { + kind: value.kind, + no_ack: value.header.no_ack, + func: value.header.func, + } + } +} + +/// Server-side request ready to be sent over the device DVC. +/// +/// Returned by the I/O request methods on [`UrbdrcDeviceServer`]. The server state machine has +/// already allocated and registered `request_id` before returning this value. +/// +/// [`UrbdrcDeviceServer`]: crate::server::UrbdrcDeviceServer +pub struct ServerIoRequest { + /// Request Identifier. + pub request_id: RequestId, + /// Whether the peer is expected to send a completion. + /// + /// This is `false` for a valid no-ack isochronous OUT transfer and `true` otherwise. + pub expects_completion: bool, + /// Request message to pass to the DVC transport. + pub message: DvcMessage, +} + +impl TsUrbOutPacket { + pub(crate) fn into_ts_urb( + self, + request_id: u32, + no_ack_isoch_write_jitter_buf_size: NoAckIsochWriteJitterBufSizeInMs, + ) -> PduResult { + if !self.kind.matches_func(self.func) { + return Err(pdu_other_err!("URB function does not match TS_URB payload")); + } + if self.no_ack + && !matches!( + self.func, + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + { + return Err(pdu_other_err!("NoAck can only be set for TS_URB_ISOCH_TRANSFER")); + } + if self.no_ack && no_ack_isoch_write_jitter_buf_size.outstanding_isoch_data().is_none() { + return Err(pdu_other_err!("NoAck is unsupported by USB device")); + } + + let ts_urb_size = self.kind.ts_urb_size()?; + Ok(TsUrbOut { + kind: self.kind, + header: TsUrbHeader { + ts_urb_size, + func: self.func, + req_id: request_id + .try_into() + .map_err(|_| pdu_other_err!("invalid transfer request id"))?, + no_ack: self.no_ack, + }, + }) + } +} + +/// Description of a redirected USB device announced by the client. +/// +/// A server backend receives this through [`UrbdrcDeviceServerBackend::add_device`] after an +/// `ADD_DEVICE` message has been decoded and its UTF-16 fields converted to Rust strings. +/// +/// [`UrbdrcDeviceServerBackend::add_device`]: crate::server::UrbdrcDeviceServerBackend::add_device +#[derive(Debug)] +pub struct DeviceAnnounce { + pub device_instance_id: String, + pub hw_ids: Vec, + pub compat_ids: Vec, + pub container_id: String, + pub usb_device_caps: UsbDeviceCaps, +} + +impl TryFrom for DeviceAnnounce { + type Error = PduError; + fn try_from(value: AddDevice) -> Result { + Ok(Self { + device_instance_id: value + .device_instance_id + .into_native() + .map_err(|e| pdu_other_err!("invalid device instance id").with_source(e))?, + hw_ids: match value.hw_ids { + Some(ids) => ids + .into_native() + .map_err(|e| pdu_other_err!("invalid hardware ids").with_source(e))?, + None => Vec::new(), + }, + compat_ids: match value.compat_ids { + Some(ids) => ids + .into_native() + .map_err(|e| pdu_other_err!("invalid compatibility id").with_source(e))?, + None => Vec::new(), + }, + container_id: value + .container_id + .into_native() + .map_err(|e| pdu_other_err!("invalid container id").with_source(e))?, + usb_device_caps: value.usb_device_caps, + }) + } +} diff --git a/crates/ironrdp-rdpeusb/src/lib.rs b/crates/ironrdp-rdpeusb/src/lib.rs index 15f99f73f2..3855acb0d6 100644 --- a/crates/ironrdp-rdpeusb/src/lib.rs +++ b/crates/ironrdp-rdpeusb/src/lib.rs @@ -6,7 +6,9 @@ extern crate alloc; pub const CHANNEL_NAME: &str = "URBDRC"; pub mod client; +pub mod io; pub mod pdu; +pub mod server; /// Error returned when a per-device USB interface ID conflicts with an RDPEUSB default interface. /// diff --git a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs index 6addf9fcc7..eda4c878f0 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs @@ -196,8 +196,7 @@ impl UrbCompletion { pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: 4 /* RequestId */ + 4 /* CbTsUrbResult */); - let req_id = RequestIdTransferInOut::try_from(src.read_u32()) - .map_err(|reason| invalid_field_err!("URB_COMPLETION::RequestId", reason))?; + let req_id = RequestIdTransferInOut::try_from(src.read_u32())?; let cb_ts_urb_result: usize = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; ensure_size!(in: src, size: cb_ts_urb_result); @@ -300,8 +299,7 @@ impl UrbCompletionNoData { pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { ensure_size!(in: src, size: 4 /* RequestId */ + 4 /* CbTsUrbResult */); - let req_id = RequestIdTransferInOut::try_from(src.read_u32()) - .map_err(|reason| invalid_field_err!("URB_COMPLETION_NO_DATA::RequestId", reason))?; + let req_id = RequestIdTransferInOut::try_from(src.read_u32())?; let cb_ts_urb_result = usize::try_from(src.read_u32()).map_err(|e| other_err!(source: e))?; ensure_size!(in: src, size: cb_ts_urb_result); diff --git a/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs b/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs index 19ea06d080..671dcf46d4 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs @@ -72,6 +72,8 @@ impl Encode for InterfaceRelease { } } +impl DvcEncode for InterfaceRelease {} + /// [\[MS-RDPEXPS\] 2.2.2.1.1 Query Interface Request (QI_REQ)][1] message. /// /// Request a new interface ID. Per [MS-RDPEXPS § 3.1.5.2.1.1] the server MUST NOT send `QI_REQ`; diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs index 11f09607fc..b5d536e682 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs @@ -15,7 +15,7 @@ use ironrdp_dvc::DvcEncode; use ironrdp_str::prefixed::Cch32String; use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; -use crate::pdu::usb_dev::ts_urb::{TsUrbIn, TsUrbOut}; +use crate::pdu::usb_dev::ts_urb::{TsUrbIn, TsUrbInKind, TsUrbOut}; use crate::pdu::utils::{HResult, RequestId, RequestIdIoctl, RequestIdTransferInOut}; #[cfg(doc)] use crate::pdu::{ @@ -277,6 +277,8 @@ impl Encode for IoControl { } } +impl DvcEncode for IoControl {} + /// [\[MS-RDPEUSB\] 2.2.12 USB IO Control Code][1]s. /// /// IO Control Codes are sent as part of an [`IoControl`] request, and these codes specify what @@ -652,29 +654,13 @@ impl TransferInRequest { } pub fn request_id(&self) -> RequestIdTransferInOut { - match &self.ts_urb { - TsUrbIn::SelectConfig(urb) => urb.header.req_id, - TsUrbIn::SelectIface(urb) => urb.header.req_id, - TsUrbIn::PipeReq(urb) => urb.header.req_id, - TsUrbIn::GetCurFrameNum(urb) => urb.header.req_id, - TsUrbIn::CtlTransfer(urb) => urb.header.req_id, - TsUrbIn::BulkInterruptTransfer(urb) => urb.header.req_id, - TsUrbIn::IsochTransfer(urb) => urb.header.req_id, - TsUrbIn::CtlDescReq(urb) => urb.header.req_id, - TsUrbIn::CtlFeatReq(urb) => urb.header.req_id, - TsUrbIn::CtlGetStatus(urb) => urb.header.req_id, - TsUrbIn::VendorClassReq(urb) => urb.header.req_id, - TsUrbIn::CtlGetConfig(urb) => urb.header.req_id, - TsUrbIn::CtlGetIface(urb) => urb.header.req_id, - TsUrbIn::OsFeatDescReq(urb) => urb.header.req_id, - TsUrbIn::CtlTransferEx(urb) => urb.header.req_id, - } + self.ts_urb.header.req_id } pub fn check_output_buffer_size(&self) -> Result<(), &'static str> { - use TsUrbIn::*; + use TsUrbInKind::*; - match self.ts_urb { + match &self.ts_urb.kind { SelectConfig(_) if self.output_buffer_size != 0 => { Err("is not: 0; TRANSFER_IN_REQUEST::TsUrb: TS_URB_SELECT_CONFIGURATION") } diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs index b050d3d4ba..3b4824c744 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs @@ -11,6 +11,7 @@ use ironrdp_core::{ Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, unsupported_value_err, write_padding, }; +use ironrdp_pdu::{PduResult, pdu_other_err}; use crate::pdu::usb_dev::ts_urb::utils::{SetupPacket, TsUrbHeader, TsUsbdInterfaceInfo, UrbFunction, UsbConfigDesc}; #[cfg(doc)] @@ -41,7 +42,61 @@ macro_rules! ensure_transfer_flag { /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/eed35296-3ca1-4271-bd0a-597138131b47 #[derive(Debug, PartialEq, Clone)] -pub enum TsUrbIn { +pub struct TsUrbIn { + pub kind: TsUrbInKind, + pub header: TsUrbHeader, +} + +impl Decode<'_> for TsUrbIn { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = TsUrbHeader::decode(src)?; + if header.no_ack { + return Err(invalid_field_err!( + "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::NoAck", + "is non-zero: NoAck MUST be set to zero for TRANSFER_IN_REQUEST" + )); + } + + let kind = TsUrbInKind::decode(src, header)?; + + Ok(Self { kind, header }) + } +} + +impl Encode for TsUrbIn { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + if self.header.no_ack { + return Err(invalid_field_err!( + "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::NoAck", + "is non-zero: NoAck MUST be set to zero for TRANSFER_IN_REQUEST" + )); + } + if !self.kind.matches_func(self.header.func) { + return Err(invalid_field_err!( + "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::URB_Function", + "does not match TS_URB payload" + )); + } + + ensure_size!(in: dst, size: self.size()); + self.header.encode_with_size(dst, self.size())?; + self.kind.encode(dst) + } + + fn name(&self) -> &'static str { + "TS_URB" + } + + fn size(&self) -> usize { + TsUrbHeader::FIXED_PART_SIZE + self.kind.size() + } +} + +/// Enumeration of all the [\[MS-RDPEUSB\] 2.2.9 TS_URB TRANSFER_IN_REQUEST Structures][1]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/eed35296-3ca1-4271-bd0a-597138131b47 +#[derive(Debug, PartialEq, Clone)] +pub enum TsUrbInKind { SelectConfig(TsUrbSelectConfig), SelectIface(TsUrbSelectInterface), PipeReq(TsUrbPipeRequest), @@ -59,52 +114,41 @@ pub enum TsUrbIn { CtlTransferEx(TsUrbControlTransferEx), } -impl Decode<'_> for TsUrbIn { - fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { - let header = TsUrbHeader::decode(src)?; - if header.no_ack { - return Err(invalid_field_err!( - "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::NoAck", - "is non-zero: NoAck MUST be set to zero for TRANSFER_IN_REQUEST" - )); - } +impl TsUrbInKind { + pub fn ts_urb_size(&self) -> PduResult { + u16::try_from(TsUrbHeader::FIXED_PART_SIZE + self.size()) + .map_err(|_| pdu_other_err!("converts usize to u16 failed")) + } + pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { let payload_size = usize::from(header.ts_urb_size) - header.size(); ensure_size!(in: src, size: payload_size); let mut src = ReadCursor::new(src.read_slice(payload_size)); let ts_urb = match header.func { - UrbFunction::URB_FUNCTION_SELECT_CONFIGURATION => { - Self::SelectConfig(TsUrbSelectConfig::decode(&mut src, header)?) - } - - UrbFunction::URB_FUNCTION_SELECT_INTERFACE => { - Self::SelectIface(TsUrbSelectInterface::decode(&mut src, header)?) - } - + UrbFunction::URB_FUNCTION_SELECT_CONFIGURATION => Self::SelectConfig(TsUrbSelectConfig::decode(&mut src)?), + UrbFunction::URB_FUNCTION_SELECT_INTERFACE => Self::SelectIface(TsUrbSelectInterface::decode(&mut src)?), UrbFunction::URB_FUNCTION_ABORT_PIPE | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE | UrbFunction::URB_FUNCTION_SYNC_CLEAR_STALL - | UrbFunction::URB_FUNCTION_CLOSE_STATIC_STREAMS => { - Self::PipeReq(TsUrbPipeRequest::decode(&mut src, header)?) - } + | UrbFunction::URB_FUNCTION_CLOSE_STATIC_STREAMS => Self::PipeReq(TsUrbPipeRequest::decode(&mut src)?), UrbFunction::URB_FUNCTION_GET_CURRENT_FRAME_NUMBER => { - Self::GetCurFrameNum(TsUrbGetCurrFrameNum::decode(&mut src, header)?) + Self::GetCurFrameNum(TsUrbGetCurrFrameNum::decode(&mut src)?) } UrbFunction::URB_FUNCTION_CONTROL_TRANSFER => { - let urb = TsUrbControlTransfer::decode(&mut src, header)?; + let urb = TsUrbControlTransfer::decode(&mut src)?; ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); Self::CtlTransfer(urb) } UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX => { - let urb = TsUrbControlTransferEx::decode(&mut src, header)?; + let urb = TsUrbControlTransferEx::decode(&mut src)?; ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); Self::CtlTransferEx(urb) } UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL => { - let urb = TsUrbBulkOrInterruptTransfer::decode(&mut src, header)?; + let urb = TsUrbBulkOrInterruptTransfer::decode(&mut src)?; ensure_transfer_flag!( TransferDirection::In, urb.transfer_flags, @@ -113,14 +157,14 @@ impl Decode<'_> for TsUrbIn { Self::BulkInterruptTransfer(urb) } UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL => { - let urb = TsUrbIsochTransfer::decode(&mut src, header)?; + let urb = TsUrbIsochTransfer::decode(&mut src)?; ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); Self::IsochTransfer(urb) } UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE => { - Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src, header)?) + Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src)?) } UrbFunction::URB_FUNCTION_SET_FEATURE_TO_DEVICE | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_INTERFACE @@ -130,13 +174,13 @@ impl Decode<'_> for TsUrbIn { | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_OTHER => { - Self::CtlFeatReq(TsUrbControlFeatRequest::decode(&mut src, header)?) + Self::CtlFeatReq(TsUrbControlFeatRequest::decode(&mut src)?) } UrbFunction::URB_FUNCTION_GET_STATUS_FROM_DEVICE | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_INTERFACE | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_OTHER => { - Self::CtlGetStatus(TsUrbControlGetStatusRequest::decode(&mut src, header)?) + Self::CtlGetStatus(TsUrbControlGetStatusRequest::decode(&mut src)?) } UrbFunction::URB_FUNCTION_VENDOR_DEVICE | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE @@ -146,7 +190,7 @@ impl Decode<'_> for TsUrbIn { | UrbFunction::URB_FUNCTION_CLASS_INTERFACE | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT | UrbFunction::URB_FUNCTION_CLASS_OTHER => { - let urb = TsUrbControlVendorClassRequest::decode(&mut src, header)?; + let urb = TsUrbControlVendorClassRequest::decode(&mut src)?; ensure_transfer_flag!( TransferDirection::In, urb.transfer_flags, @@ -155,26 +199,99 @@ impl Decode<'_> for TsUrbIn { Self::VendorClassReq(urb) } UrbFunction::URB_FUNCTION_GET_CONFIGURATION => { - Self::CtlGetConfig(TsUrbControlGetConfigRequest::decode(&mut src, header)?) + Self::CtlGetConfig(TsUrbControlGetConfigRequest::decode(&mut src)?) } UrbFunction::URB_FUNCTION_GET_INTERFACE => { - Self::CtlGetIface(TsUrbControlGetInterfaceRequest::decode(&mut src, header)?) + Self::CtlGetIface(TsUrbControlGetInterfaceRequest::decode(&mut src)?) } UrbFunction::URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR => { - Self::OsFeatDescReq(TsUrbOsFeatDescRequest::decode(&mut src, header)?) + Self::OsFeatDescReq(TsUrbOsFeatDescRequest::decode(&mut src)?) } func => return Err(unsupported_value_err!("URB Function", format!("{}", u16::from(func)))), }; Ok(ts_urb) } + + pub(crate) fn matches_func(&self, func: UrbFunction) -> bool { + matches!( + (self, func), + (Self::SelectConfig(_), UrbFunction::URB_FUNCTION_SELECT_CONFIGURATION) + | (Self::SelectIface(_), UrbFunction::URB_FUNCTION_SELECT_INTERFACE) + | ( + Self::PipeReq(_), + UrbFunction::URB_FUNCTION_ABORT_PIPE + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE + | UrbFunction::URB_FUNCTION_SYNC_CLEAR_STALL + | UrbFunction::URB_FUNCTION_CLOSE_STATIC_STREAMS + ) + | ( + Self::GetCurFrameNum(_), + UrbFunction::URB_FUNCTION_GET_CURRENT_FRAME_NUMBER + ) + | (Self::CtlTransfer(_), UrbFunction::URB_FUNCTION_CONTROL_TRANSFER) + | ( + Self::BulkInterruptTransfer(_), + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL + ) + | ( + Self::IsochTransfer(_), + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER + | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + | ( + Self::CtlDescReq(_), + UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE + ) + | ( + Self::CtlFeatReq(_), + UrbFunction::URB_FUNCTION_SET_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_OTHER + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_OTHER + ) + | ( + Self::CtlGetStatus(_), + UrbFunction::URB_FUNCTION_GET_STATUS_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_INTERFACE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_OTHER + ) + | ( + Self::VendorClassReq(_), + UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER + | UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER + ) + | (Self::CtlGetConfig(_), UrbFunction::URB_FUNCTION_GET_CONFIGURATION) + | (Self::CtlGetIface(_), UrbFunction::URB_FUNCTION_GET_INTERFACE) + | ( + Self::OsFeatDescReq(_), + UrbFunction::URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR + ) + | (Self::CtlTransferEx(_), UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX) + ) + } } -impl Encode for TsUrbIn { +impl Encode for TsUrbInKind { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - use TsUrbIn::*; + use TsUrbInKind::*; match self { SelectConfig(urb) => urb.encode(dst), SelectIface(urb) => urb.encode(dst), @@ -217,12 +334,8 @@ impl Encode for TsUrbIn { } } - fn name(&self) -> &'static str { - "TS_URB" - } - fn size(&self) -> usize { - use TsUrbIn::*; + use TsUrbInKind::*; match self { SelectConfig(urb) => urb.size(), SelectIface(urb) => urb.size(), @@ -241,13 +354,68 @@ impl Encode for TsUrbIn { CtlTransferEx(urb) => urb.size(), } } + + fn name(&self) -> &'static str { + "TS_URB" + } +} + +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbOut { + pub kind: TsUrbOutKind, + pub header: TsUrbHeader, +} + +impl Decode<'_> for TsUrbOut { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = TsUrbHeader::decode(src)?; + + let kind = TsUrbOutKind::decode(src, header)?; + Ok(Self { kind, header }) + } +} + +impl Encode for TsUrbOut { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + if !self.kind.matches_func(self.header.func) { + return Err(invalid_field_err!( + "TRANSFER_OUT_REQUEST::TsUrb::TS_URB_HEADER::URB_Function", + "does not match TS_URB payload" + )); + } + if self.header.no_ack + && !matches!( + self.header.func, + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + { + return Err(invalid_field_err!( + "TRANSFER_OUT_REQUEST::TsUrb::TS_URB_HEADER::NoAck", + "can only be set for TS_URB_ISOCH_TRANSFER" + )); + } + + ensure_size!(in: dst, size: self.size()); + self.header.encode_with_size(dst, self.size())?; + self.kind.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB" + } + + fn size(&self) -> usize { + TsUrbHeader::FIXED_PART_SIZE + self.kind.size() + } } /// Enumeration of all the [\[MS-RDPEUSB\] 2.2.9 TS_URB TRANSFER_OUT_REQUEST Structures][1]. /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/eed35296-3ca1-4271-bd0a-597138131b47 #[derive(Debug, PartialEq, Clone)] -pub enum TsUrbOut { +pub enum TsUrbOutKind { CtlTransfer(TsUrbControlTransfer), BulkInterruptTransfer(TsUrbBulkOrInterruptTransfer), IsochTransfer(TsUrbIsochTransfer), @@ -256,28 +424,31 @@ pub enum TsUrbOut { CtlTransferEx(TsUrbControlTransferEx), } -impl Decode<'_> for TsUrbOut { - fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { - let header = TsUrbHeader::decode(src)?; +impl TsUrbOutKind { + pub fn ts_urb_size(&self) -> PduResult { + u16::try_from(TsUrbHeader::FIXED_PART_SIZE + self.size()) + .map_err(|_| pdu_other_err!("converts usize to u16 failed")) + } + pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { let payload_size = usize::from(header.ts_urb_size) - header.size(); ensure_size!(in: src, size: payload_size); let mut src = ReadCursor::new(src.read_slice(payload_size)); let ts_urb = match header.func { UrbFunction::URB_FUNCTION_CONTROL_TRANSFER => { - let urb = TsUrbControlTransfer::decode(&mut src, header)?; + let urb = TsUrbControlTransfer::decode(&mut src)?; ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); Self::CtlTransfer(urb) } UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX => { - let urb = TsUrbControlTransferEx::decode(&mut src, header)?; + let urb = TsUrbControlTransferEx::decode(&mut src)?; ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); Self::CtlTransferEx(urb) } UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL => { - let urb = TsUrbBulkOrInterruptTransfer::decode(&mut src, header)?; + let urb = TsUrbBulkOrInterruptTransfer::decode(&mut src)?; ensure_transfer_flag!( TransferDirection::Out, urb.transfer_flags, @@ -286,14 +457,14 @@ impl Decode<'_> for TsUrbOut { Self::BulkInterruptTransfer(urb) } UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL => { - let urb = TsUrbIsochTransfer::decode(&mut src, header)?; + let urb = TsUrbIsochTransfer::decode(&mut src)?; ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); Self::IsochTransfer(urb) } UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE => { - Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src, header)?) + Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src)?) } UrbFunction::URB_FUNCTION_VENDOR_DEVICE | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE @@ -303,7 +474,7 @@ impl Decode<'_> for TsUrbOut { | UrbFunction::URB_FUNCTION_CLASS_INTERFACE | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT | UrbFunction::URB_FUNCTION_CLASS_OTHER => { - let urb = TsUrbControlVendorClassRequest::decode(&mut src, header)?; + let urb = TsUrbControlVendorClassRequest::decode(&mut src)?; ensure_transfer_flag!( TransferDirection::Out, urb.transfer_flags, @@ -316,11 +487,46 @@ impl Decode<'_> for TsUrbOut { Ok(ts_urb) } + + pub(crate) fn matches_func(&self, func: UrbFunction) -> bool { + matches!( + (self, func), + (Self::CtlTransfer(_), UrbFunction::URB_FUNCTION_CONTROL_TRANSFER) + | ( + Self::BulkInterruptTransfer(_), + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL + ) + | ( + Self::IsochTransfer(_), + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER + | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + | ( + Self::CtlDescReq(_), + UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE + ) + | ( + Self::VendorClassReq(_), + UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER + | UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER + ) + | (Self::CtlTransferEx(_), UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX) + ) + } } -impl Encode for TsUrbOut { +impl Encode for TsUrbOutKind { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - use TsUrbOut::*; + use TsUrbOutKind::*; match self { CtlTransfer(urb) => { ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); @@ -355,7 +561,7 @@ impl Encode for TsUrbOut { } fn size(&self) -> usize { - use TsUrbOut::*; + use TsUrbOutKind::*; match self { CtlTransfer(urb) => urb.size(), BulkInterruptTransfer(urb) => urb.size(), @@ -388,13 +594,12 @@ pub(crate) enum TransferDirection { #[doc(alias = "TS_URB_SELECT_CONFIGURATION")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbSelectConfig { - pub header: TsUrbHeader, pub usbd_ifaces: Vec, pub desc: Option, } -impl TsUrbSelectConfig { - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { +impl Decode<'_> for TsUrbSelectConfig { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { let desc = src.read_u8(/* ConfigurationDescriptorIsValid */) != 0; ensure_size!(in: src, size: const { 3 * size_of::() }); @@ -413,30 +618,13 @@ impl TsUrbSelectConfig { let desc = if desc { Some(UsbConfigDesc::decode(src)?) } else { None }; - Ok(Self { - header, - usbd_ifaces, - desc, - }) + Ok(Self { usbd_ifaces, desc }) } } impl Encode for TsUrbSelectConfig { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::URB_FUNCTION_SELECT_CONFIGURATION) { - return Err(invalid_field_err!( - "TS_URB_SELECT_CONFIGURATION::TS_URB_HEADER::URB_Function", - "is not URB_FUNCTION_SELECT_CONFIGURATION" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_SELECT_CONFIGURATION::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } ensure_size!(in: dst, size: self.size()); - self.header.encode_with_size(dst, self.size())?; // ConfigurationDescriptorIsValid dst.write_u8(self.desc.is_some().into()); @@ -468,12 +656,9 @@ impl Encode for TsUrbSelectConfig { } fn size(&self) -> usize { - TsUrbHeader::FIXED_PART_SIZE - + const { - size_of::(/* ConfigurationDescriptorIsValid */) - + (3 * size_of::()/* Padding */) - + size_of::(/* NumInterfaces */) - } + 1 /* ConfigurationDescriptorIsValid */ + + 3 /* Padding */ + + 4 /* NumInterfaces */ + self.usbd_ifaces.iter().map(Encode::size).sum::() + self.desc.as_ref().map(Encode::size).unwrap_or_default() } @@ -489,13 +674,12 @@ impl Encode for TsUrbSelectConfig { #[doc(alias = "TS_URB_SELECT_INTERFACE")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbSelectInterface { - pub header: TsUrbHeader, pub config_handle: ConfigHandle, pub usbd_iface: TsUsbdInterfaceInfo, } -impl TsUrbSelectInterface { - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { +impl Decode<'_> for TsUrbSelectInterface { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { ensure_size!(in: src, size: const { size_of::(/* ConfigurationHandle */) }); @@ -505,7 +689,6 @@ impl TsUrbSelectInterface { let usbd_iface = TsUsbdInterfaceInfo::decode(src)?; Ok(Self { - header, config_handle, usbd_iface, }) @@ -514,21 +697,7 @@ impl TsUrbSelectInterface { impl Encode for TsUrbSelectInterface { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::URB_FUNCTION_SELECT_INTERFACE) { - return Err(invalid_field_err!( - "TS_URB_SELECT_INTERFACE::TS_URB_HEADER::URB_Function", - "is not URB_FUNCTION_SELECT_INTERFACE" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_SELECT_INTERFACE::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } - ensure_size!(in: dst, size: self.size()); - self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.config_handle); self.usbd_iface.encode(dst) } @@ -538,11 +707,9 @@ impl Encode for TsUrbSelectInterface { } fn size(&self) -> usize { - TsUrbHeader::FIXED_PART_SIZE - + const { - size_of::(/* ConfigurationHandle */) - } - + self.usbd_iface.size() + (const { + size_of::(/* ConfigurationHandle */) + }) + self.usbd_iface.size() } } @@ -556,51 +723,26 @@ impl Encode for TsUrbSelectInterface { #[doc(alias = "TS_URB_PIPE_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbPipeRequest { - pub header: TsUrbHeader, pub pipe_handle: PipeHandle, } impl TsUrbPipeRequest { - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + size_of::(/* PipeHandle */); + pub const FIXED_PART_SIZE: usize = size_of::(/* PipeHandle */); +} - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: const { size_of::(/* PipeHandle */) }); +impl Decode<'_> for TsUrbPipeRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let pipe_handle = src.read_u32(); - Ok(Self { header, pipe_handle }) + Ok(Self { pipe_handle }) } } impl Encode for TsUrbPipeRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!( - self.header.func, - UrbFunction::URB_FUNCTION_ABORT_PIPE - | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL - | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE - | UrbFunction::URB_FUNCTION_SYNC_CLEAR_STALL - | UrbFunction::URB_FUNCTION_CLOSE_STATIC_STREAMS - ) { - return Err(invalid_field_err!( - "TS_URB_PIPE_REQUEST::TS_URB_HEADER::URB_Function", - "is not one of: \ - URB_FUNCTION_ABORT_PIPE, \ - URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL, \ - URB_FUNCTION_SYNC_RESET_PIPE, \ - URB_FUNCTION_SYNC_CLEAR_STALL, \ - URB_FUNCTION_CLOSE_STATIC_STREAMS" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_PIPE_REQUEST::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } - ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe_handle); Ok(()) @@ -624,34 +766,22 @@ impl Encode for TsUrbPipeRequest { /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_get_current_frame_number #[doc(alias = "TS_URB_GET_CURRENT_FRAME_NUMBER")] #[derive(Debug, PartialEq, Clone)] -pub struct TsUrbGetCurrFrameNum { - pub header: TsUrbHeader, -} +pub struct TsUrbGetCurrFrameNum; impl TsUrbGetCurrFrameNum { - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE; + pub const FIXED_PART_SIZE: usize = 0; +} - #[inline] - pub fn decode(_: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - Ok(Self { header }) +impl Decode<'_> for TsUrbGetCurrFrameNum { + fn decode(_: &mut ReadCursor<'_>) -> DecodeResult { + Ok(Self) } } impl Encode for TsUrbGetCurrFrameNum { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::URB_FUNCTION_GET_CURRENT_FRAME_NUMBER) { - return Err(invalid_field_err!( - "TS_URB_GET_CURRENT_FRAME_NUMBER::TS_URB_HEADER::URB_Function", - "is not URB_FUNCTION_GET_CURRENT_FRAME_NUMBER" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_GET_CURRENT_FRAME_NUMBER::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } - self.header.encode_with_size(dst, self.size()) + ensure_fixed_part_size!(in: dst); + Ok(()) } fn name(&self) -> &'static str { @@ -674,27 +804,25 @@ impl Encode for TsUrbGetCurrFrameNum { #[doc(alias = "TS_URB_CONTROL_TRANSFER")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbControlTransfer { - pub header: TsUrbHeader, pub pipe: PipeHandle, pub transfer_flags: u32, pub setup_packet: SetupPacket, } impl TsUrbControlTransfer { - pub const PAYLOAD_SIZE: usize = + pub const FIXED_PART_SIZE: usize = size_of::(/* PipeHandle */) + size_of::(/* TransferFlags */) + SetupPacket::FIXED_PART_SIZE; +} - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; - - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbControlTransfer { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let pipe_handle = src.read_u32(); let transfer_flags = src.read_u32(); let setup_packet = SetupPacket::decode(src)?; Ok(Self { - header, pipe: pipe_handle, transfer_flags, setup_packet, @@ -704,14 +832,7 @@ impl TsUrbControlTransfer { impl Encode for TsUrbControlTransfer { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::URB_FUNCTION_CONTROL_TRANSFER) { - return Err(invalid_field_err!( - "TS_URB_CONTROL_TRANSFER::TS_URB_HEADER::URB_Function", - "is not URB_FUNCTION_CONTROL_TRANSFER" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe); dst.write_u32(self.transfer_flags); self.setup_packet.encode(dst) @@ -737,24 +858,22 @@ impl Encode for TsUrbControlTransfer { #[doc(alias = "TS_URB_BULK_OR_INTERRUPT_TRANSFER")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbBulkOrInterruptTransfer { - pub header: TsUrbHeader, pub pipe_handle: PipeHandle, pub transfer_flags: u32, } impl TsUrbBulkOrInterruptTransfer { - pub const PAYLOAD_SIZE: usize = size_of::(/* PipeHandle */) + size_of::(/* TransferFlags */); - - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = size_of::(/* PipeHandle */) + size_of::(/* TransferFlags */); +} - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbBulkOrInterruptTransfer { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let pipe_handle = src.read_u32(); let transfer_flags = src.read_u32(); Ok(Self { - header, pipe_handle, transfer_flags, }) @@ -763,19 +882,8 @@ impl TsUrbBulkOrInterruptTransfer { impl Encode for TsUrbBulkOrInterruptTransfer { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!( - self.header.func, - UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER - | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL - ) { - return Err(invalid_field_err!( - "TS_URB_BULK_OR_INTERRUPT_TRANSFER::TS_URB_HEADER::URB_Function", - "is not one of: URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER, URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe_handle); dst.write_u32(self.transfer_flags); @@ -802,7 +910,6 @@ impl Encode for TsUrbBulkOrInterruptTransfer { #[doc(alias = "TS_URB_ISOCH_TRANSFER")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbIsochTransfer { - pub header: TsUrbHeader, pub pipe_handle: PipeHandle, pub transfer_flags: u32, pub start_frame: FrameNumber, @@ -810,8 +917,8 @@ pub struct TsUrbIsochTransfer { pub iso_packet: Vec, } -impl TsUrbIsochTransfer { - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { +impl Decode<'_> for TsUrbIsochTransfer { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { ensure_size!(in: src, size: 20); let pipe_handle = src.read_u32(); @@ -826,7 +933,6 @@ impl TsUrbIsochTransfer { .collect::, _>>()?; Ok(Self { - header, pipe_handle, transfer_flags, start_frame, @@ -838,18 +944,8 @@ impl TsUrbIsochTransfer { impl Encode for TsUrbIsochTransfer { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!( - self.header.func, - UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL - ) { - return Err(invalid_field_err!( - "TS_URB_ISOCH_TRANSFER::TS_URB_HEADER::URB_Function", - "is not one of: URB_FUNCTION_ISOCH_TRANSFER, URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL" - )); - } ensure_size!(in: dst, size: self.size()); - self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe_handle); dst.write_u32(self.transfer_flags); dst.write_u32(self.start_frame); @@ -868,14 +964,11 @@ impl Encode for TsUrbIsochTransfer { } fn size(&self) -> usize { - TsUrbHeader::FIXED_PART_SIZE - + const { - size_of::() - + size_of::(/* TransferFlags */) - + size_of::(/* StartFrame */) - + size_of::(/* NumberOfPackets */) - + size_of::(/* ErrorCount */) - } + size_of::() + + size_of::(/* TransferFlags */) + + size_of::(/* StartFrame */) + + size_of::(/* NumberOfPackets */) + + size_of::(/* ErrorCount */) + self.iso_packet.len() * UsbdIsoPacketDesc::FIXED_PART_SIZE } } @@ -894,27 +987,25 @@ impl Encode for TsUrbIsochTransfer { #[doc(alias = "TS_URB_CONTROL_DESCRIPTOR_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbControlDescRequest { - pub header: TsUrbHeader, pub index: u8, pub desc_type: u8, pub lang_id: u16, } impl TsUrbControlDescRequest { - pub const PAYLOAD_SIZE: usize = + pub const FIXED_PART_SIZE: usize = size_of::(/* Index */) + size_of::(/* DescriptorType */) + size_of::(/* LanguageId */); +} - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; - - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbControlDescRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let index = src.read_u8(); let desc_type = src.read_u8(); let lang_id = src.read_u16(); Ok(Self { - header, index, desc_type, lang_id, @@ -924,30 +1015,8 @@ impl TsUrbControlDescRequest { impl Encode for TsUrbControlDescRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - #[expect(unused_parens)] - if !matches!( - self.header.func, - (UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE - | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT - | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE) - | (UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE - | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT - | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE) - ) { - return Err(invalid_field_err!( - "TS_URB_CONTROL_DESCRIPTOR_REQUEST::TS_URB_HEADER::URB_Function", - "is not one of: \ - URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE, \ - URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT, \ - URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE, \ - URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE, \ - URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT, \ - URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u8(self.index); dst.write_u8(self.desc_type); dst.write_u16(self.lang_id); @@ -974,66 +1043,29 @@ impl Encode for TsUrbControlDescRequest { #[doc(alias = "TS_URB_CONTROL_FEATURE_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbControlFeatRequest { - pub header: TsUrbHeader, pub feat_selector: u16, pub index: u16, } impl TsUrbControlFeatRequest { - pub const PAYLOAD_SIZE: usize = size_of::(/* FeatureSelector */) + size_of::(/* Index */); - - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = size_of::(/* FeatureSelector */) + size_of::(/* Index */); +} - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbControlFeatRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let feat_selector = src.read_u16(); let index = src.read_u16(); - Ok(Self { - header, - feat_selector, - index, - }) + Ok(Self { feat_selector, index }) } } impl Encode for TsUrbControlFeatRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - #[expect(unused_parens)] - if !matches!( - self.header.func, - (UrbFunction::URB_FUNCTION_SET_FEATURE_TO_DEVICE - | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_INTERFACE - | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_ENDPOINT - | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_OTHER) - | (UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE - | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE - | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT - | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_OTHER) - ) { - return Err(invalid_field_err!( - "TS_URB_CONTROL_FEATURE_REQUEST::TS_URB_HEADER::URB_Function", - "is not one of: \ - URB_FUNCTION_SET_FEATURE_TO_DEVICE, \ - URB_FUNCTION_SET_FEATURE_TO_INTERFACE, \ - URB_FUNCTION_SET_FEATURE_TO_ENDPOINT, \ - URB_FUNCTION_SET_FEATURE_TO_OTHER, \ - URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE, \ - URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE, \ - URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT, \ - URB_FUNCTION_CLEAR_FEATURE_TO_OTHER" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_CONTROL_FEATURE_REQUEST::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u16(self.feat_selector); dst.write_u16(self.index); @@ -1059,52 +1091,28 @@ impl Encode for TsUrbControlFeatRequest { #[doc(alias = "TS_URB_CONTROL_GET_STATUS_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbControlGetStatusRequest { - pub header: TsUrbHeader, pub index: u16, } impl TsUrbControlGetStatusRequest { - pub const PAYLOAD_SIZE: usize = size_of::(/* Index */) + size_of::(/* Padding */); - - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = size_of::(/* Index */) + size_of::(/* Padding */); +} - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbControlGetStatusRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let index = src.read_u16(); read_padding!(src, 2); - Ok(Self { header, index }) + Ok(Self { index }) } } impl Encode for TsUrbControlGetStatusRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!( - self.header.func, - UrbFunction::URB_FUNCTION_GET_STATUS_FROM_DEVICE - | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_INTERFACE - | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT - | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_OTHER - ) { - return Err(invalid_field_err!( - "TS_URB_CONTROL_GET_STATUS_REQUEST::TS_URB_HEADER::URB_Function", - "is not one of: \ - URB_FUNCTION_GET_STATUS_FROM_DEVICE, \ - URB_FUNCTION_GET_STATUS_FROM_INTERFACE, \ - URB_FUNCTION_GET_STATUS_FROM_ENDPOINT, \ - URB_FUNCTION_GET_STATUS_FROM_OTHER" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_CONTROL_GET_STATUS_REQUEST::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u16(self.index); write_padding!(dst, 2); @@ -1131,7 +1139,6 @@ impl Encode for TsUrbControlGetStatusRequest { #[doc(alias = "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbControlVendorClassRequest { - pub header: TsUrbHeader, pub transfer_flags: u32, pub request: u8, pub value: u16, @@ -1139,17 +1146,17 @@ pub struct TsUrbControlVendorClassRequest { } impl TsUrbControlVendorClassRequest { - pub const PAYLOAD_SIZE: usize = size_of::() + pub const FIXED_PART_SIZE: usize = size_of::() + size_of::(/* RequestTypeReservedBits */) + size_of::(/* Request */) + size_of::(/* Value */) + size_of::(/* Index */) + size_of::(/* Padding */); +} - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; - - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbControlVendorClassRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let transfer_flags: u32 = src.read_u32(); src.advance(1); // RequestTypeReservedBits @@ -1159,7 +1166,6 @@ impl TsUrbControlVendorClassRequest { read_padding!(src, 2); Ok(Self { - header, transfer_flags, request, value, @@ -1170,34 +1176,8 @@ impl TsUrbControlVendorClassRequest { impl Encode for TsUrbControlVendorClassRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - #[expect(unused_parens)] - if !matches!( - self.header.func, - (UrbFunction::URB_FUNCTION_VENDOR_DEVICE - | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE - | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT - | UrbFunction::URB_FUNCTION_VENDOR_OTHER) - | (UrbFunction::URB_FUNCTION_CLASS_DEVICE - | UrbFunction::URB_FUNCTION_CLASS_INTERFACE - | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT - | UrbFunction::URB_FUNCTION_CLASS_OTHER) - ) { - return Err(invalid_field_err!( - "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST::TS_URB_HEADER::URB_Function", - "is not one of: \ - URB_FUNCTION_VENDOR_DEVICE, \ - URB_FUNCTION_VENDOR_INTERFACE, \ - URB_FUNCTION_VENDOR_ENDPOINT, \ - URB_FUNCTION_VENDOR_OTHER, \ - URB_FUNCTION_CLASS_DEVICE, \ - URB_FUNCTION_CLASS_INTERFACE, \ - URB_FUNCTION_CLASS_ENDPOINT, \ - URB_FUNCTION_CLASS_OTHER" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.transfer_flags); write_padding!(dst, 1); // RequestTypeReservedBits dst.write_u8(self.request); @@ -1225,35 +1205,22 @@ impl Encode for TsUrbControlVendorClassRequest { /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_get_configuration_request #[doc(alias = "TS_URB_CONTROL_GET_CONFIGURATION_REQUEST")] #[derive(Debug, PartialEq, Clone)] -pub struct TsUrbControlGetConfigRequest { - pub header: TsUrbHeader, -} +pub struct TsUrbControlGetConfigRequest; impl TsUrbControlGetConfigRequest { - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE; + pub const FIXED_PART_SIZE: usize = 0; +} - #[inline] - pub fn decode(_: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - Ok(Self { header }) +impl Decode<'_> for TsUrbControlGetConfigRequest { + fn decode(_: &mut ReadCursor<'_>) -> DecodeResult { + Ok(Self) } } impl Encode for TsUrbControlGetConfigRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::URB_FUNCTION_GET_CONFIGURATION) { - return Err(invalid_field_err!( - "TS_URB_CONTROL_GET_CONFIGURATION_REQUEST::TS_URB_HEADER::URB_Function", - "is not URB_FUNCTION_GET_CONFIGURATION" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_CONTROL_GET_CONFIGURATION_REQUEST::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size()) + Ok(()) } fn name(&self) -> &'static str { @@ -1275,40 +1242,26 @@ impl Encode for TsUrbControlGetConfigRequest { #[doc(alias = "TS_URB_CONTROL_GET_INTERFACE_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbControlGetInterfaceRequest { - pub header: TsUrbHeader, pub interface: u16, } impl TsUrbControlGetInterfaceRequest { - pub const PAYLOAD_SIZE: usize = size_of::(/* Interface */) + size_of::(/* Padding */); - - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; + pub const FIXED_PART_SIZE: usize = size_of::(/* Interface */) + size_of::(/* Padding */); +} - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbControlGetInterfaceRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let interface = src.read_u16(); read_padding!(src, 2); - Ok(Self { header, interface }) + Ok(Self { interface }) } } impl Encode for TsUrbControlGetInterfaceRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::URB_FUNCTION_GET_INTERFACE) { - return Err(invalid_field_err!( - "TS_URB_CONTROL_GET_INTERFACE_REQUEST::TS_URB_HEADER::URB_Function", - "is not URB_FUNCTION_GET_INTERFACE" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_CONTROL_GET_INTERFACE_REQUEST::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u16(self.interface); write_padding!(dst, 2); @@ -1334,23 +1287,22 @@ impl Encode for TsUrbControlGetInterfaceRequest { #[doc(alias = "TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbOsFeatDescRequest { - pub header: TsUrbHeader, pub recipient: u8, pub interface_number: u8, pub ms_feat_desc_index: u16, } impl TsUrbOsFeatDescRequest { - pub const PAYLOAD_SIZE: usize = size_of::(/* Recipient + Padding1 */) + pub const FIXED_PART_SIZE: usize = size_of::(/* Recipient + Padding1 */) + size_of::(/* InterfaceNumber */) + size_of::(/* MS_PageIndex */) + size_of::(/* MS_FeatureDescriptorIndex */) + (3 * size_of::()/* Padding2 */); +} - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; - - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbOsFeatDescRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let recipient = src.read_u8() & 0x1F; let interface_number = src.read_u8(); @@ -1365,7 +1317,6 @@ impl TsUrbOsFeatDescRequest { read_padding!(src, 3); Ok(Self { - header, recipient, interface_number, ms_feat_desc_index, @@ -1375,20 +1326,7 @@ impl TsUrbOsFeatDescRequest { impl Encode for TsUrbOsFeatDescRequest { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR) { - return Err(invalid_field_err!( - "TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST::TS_URB_HEADER::URB_Function", - "is not URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR" - )); - } - if self.header.no_ack { - return Err(invalid_field_err!( - "TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST::TS_URB_HEADER::URB_Function::NoAck", - "is non-zero" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u8(self.recipient & 0x1F); dst.write_u8(self.interface_number); dst.write_u8(0x0); // MS_PageIndex @@ -1418,7 +1356,6 @@ impl Encode for TsUrbOsFeatDescRequest { #[doc(alias = "TS_URB_CONTROL_TRANSFER_EX")] #[derive(Debug, PartialEq, Clone)] pub struct TsUrbControlTransferEx { - pub header: TsUrbHeader, pub pipe: PipeHandle, pub transfer_flags: u32, pub timeout: u32, @@ -1426,15 +1363,15 @@ pub struct TsUrbControlTransferEx { } impl TsUrbControlTransferEx { - pub const PAYLOAD_SIZE: usize = size_of::() + pub const FIXED_PART_SIZE: usize = size_of::() + size_of::(/* TransferFlags */) + size_of::(/* Timeout */) + SetupPacket::FIXED_PART_SIZE; +} - pub const FIXED_PART_SIZE: usize = TsUrbHeader::FIXED_PART_SIZE + Self::PAYLOAD_SIZE; - - pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); +impl Decode<'_> for TsUrbControlTransferEx { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); let pipe_handle = src.read_u32(); let transfer_flags = src.read_u32(); @@ -1442,7 +1379,6 @@ impl TsUrbControlTransferEx { let setup_packet = SetupPacket::decode(src)?; Ok(Self { - header, pipe: pipe_handle, transfer_flags, timeout, @@ -1453,14 +1389,7 @@ impl TsUrbControlTransferEx { impl Encode for TsUrbControlTransferEx { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - if !matches!(self.header.func, UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX) { - return Err(invalid_field_err!( - "TS_URB_CONTROL_TRANSFER_EX::TS_URB_HEADER::URB_Function", - "is not URB_FUNCTION_CONTROL_TRANSFER_EX" - )); - } ensure_fixed_part_size!(in: dst); - self.header.encode_with_size(dst, self.size())?; dst.write_u32(self.pipe); dst.write_u32(self.transfer_flags); dst.write_u32(self.timeout); diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs index 54431578f4..d1e9202e09 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs @@ -16,7 +16,7 @@ use crate::pdu::{ TsUrbBulkOrInterruptTransfer, TsUrbControlDescRequest, TsUrbControlFeatRequest, TsUrbControlGetConfigRequest, TsUrbControlGetInterfaceRequest, TsUrbControlGetStatusRequest, TsUrbControlTransfer, TsUrbControlTransferEx, TsUrbControlVendorClassRequest, TsUrbGetCurrFrameNum, TsUrbIn, TsUrbIsochTransfer, TsUrbOsFeatDescRequest, - TsUrbOut, TsUrbPipeRequest, TsUrbSelectConfig, TsUrbSelectInterface, + TsUrbOutKind, TsUrbPipeRequest, TsUrbSelectConfig, TsUrbSelectInterface, }, }; @@ -297,7 +297,7 @@ impl From for u16 { /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/578da9ca-3116-4608-9737-1bf3df4de3d1 #[doc(alias = "TS_URB_HEADER")] -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Copy)] pub struct TsUrbHeader { /// The size in bytes of the TS_URB structure. pub ts_urb_size: u16, diff --git a/crates/ironrdp-rdpeusb/src/pdu/utils.rs b/crates/ironrdp-rdpeusb/src/pdu/utils.rs index 3374bb85cb..0daffbdad3 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/utils.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/utils.rs @@ -2,7 +2,10 @@ //! //! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a1004d0e-99e9-4968-894b-0b924ef2f125 -use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; +use ironrdp_core::{ + Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, + invalid_field_err, +}; #[cfg(doc)] use crate::pdu::usb_dev::{InternalIoControl, IoControl, TransferInRequest, TransferOutRequest}; @@ -45,13 +48,16 @@ pub const MAX_NON_DEFAULT_EP_COUNT: usize = 30; pub struct RequestIdTransferInOut(u32); impl TryFrom for RequestIdTransferInOut { - type Error = &'static str; + type Error = DecodeError; fn try_from(value: u32) -> Result { if value <= 0x7F_FF_FF_FF { Ok(RequestIdTransferInOut(value)) } else { - Err("value greater than 31 bits") + Err(invalid_field_err!( + "TsUrbHeader::RequestId", + "value greater than 31 bits" + )) } } } diff --git a/crates/ironrdp-rdpeusb/src/server.rs b/crates/ironrdp-rdpeusb/src/server.rs new file mode 100644 index 0000000000..9afcdb1a4e --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/server.rs @@ -0,0 +1,659 @@ +use alloc::collections::btree_map::{BTreeMap, Entry}; +use alloc::vec::Vec; +use alloc::{boxed::Box, vec}; +use ironrdp_core::{Decode as _, ReadCursor, impl_as_any}; +use ironrdp_dvc::{DvcMessage, DvcProcessor, DvcServerProcessor}; +use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; + +use crate::io::{ + DeviceAnnounce, DeviceText, InternalIoControlPacket, IoControlCompletionResult, IoControlPacket, ServerIoRequest, + TransferInCompletionResult, TransferInPacket, TransferOutCompletionResult, TransferOutPacket, UsbRetractReason, +}; +use crate::pdu::caps::RimExchangeCapabilityRequest; +use crate::pdu::completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}; +use crate::pdu::header::{InterfaceId, Mask, MessageId}; +use crate::pdu::iface_manipulation::{InterfaceRelease, QueryInterfaceFailureResponse}; +use crate::pdu::notify::ChannelCreated; +use crate::pdu::sink::NoAckIsochWriteJitterBufSizeInMs; +use crate::pdu::usb_dev::{ + CancelRequest, QueryDeviceText, RegisterRequestCallback, RetractDevice, TransferInRequest, TransferOutRequest, +}; +use crate::pdu::utils::RequestId; +use crate::pdu::{UrbdrcClientControlPdu, UrbdrcClientDevicePdu}; +use crate::{CHANNEL_NAME, InvalidDeviceInterfaceId}; + +pub struct UrbdrcControlServer { + msg_id_alloc: IdAllocator, + state: State, + backend: Box, +} + +pub trait UrbdrcControlServerBackend: Send { + /// The server makes a new instance of a dynamic virtual channel for USB redirection. + fn create_device_chan(&mut self) -> PduResult<()>; +} + +#[derive(PartialEq)] +enum State { + CapsExchanging, + CapsExchanged, + Ready, +} + +impl UrbdrcControlServer { + pub fn new(backend: Box) -> Self { + Self { + msg_id_alloc: IdAllocator::new(), + state: State::CapsExchanging, + backend, + } + } +} + +struct IdAllocator { + id: u32, +} + +impl IdAllocator { + #[inline] + const fn new() -> Self { + Self { id: 0 } + } + + #[inline] + const fn alloc(&mut self) -> MessageId { + self.id += 1; + self.id + } +} + +struct RequestIdAllocator { + id: u32, +} + +impl RequestIdAllocator { + #[inline] + const fn new() -> Self { + Self { id: 0 } + } + + #[inline] + const fn alloc(&mut self) -> u32 { + self.id += 1; + if self.id > 0x7F_FF_FF_FF { + self.id = 0; + } + self.id + } +} + +impl DvcProcessor for UrbdrcControlServer { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(vec![Box::new(RimExchangeCapabilityRequest { + msg_id: self.msg_id_alloc.alloc(), + capability: crate::pdu::caps::Capability::RimCapabilityVersion01, + })]) + } + + fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = UrbdrcClientControlPdu::decode(&mut ReadCursor::new(payload)).map_err(|e| decode_err!(e))?; + + let mut resp: Vec = Vec::new(); + use UrbdrcClientControlPdu::*; + match pdu { + IfaceRelease(_iface_release_pdu) => Ok(resp), + QueryIfaceReq(query_req_pdu) => { + resp.push(Box::new(QueryInterfaceFailureResponse { + msg_id: query_req_pdu.msg_id, + iface_id: query_req_pdu.iface_id, + })); + Ok(resp) + } + Caps(_caps_response_pdu) => { + if self.state != State::CapsExchanging { + return Err(pdu_other_err!("invalid state")); + } + resp.push(Box::new(InterfaceRelease { + iface_id: InterfaceId::CAPABILITIES.with_mask(Mask::None), + msg_id: self.msg_id_alloc.alloc(), + })); + resp.push(Box::new(ChannelCreated { + msg_id: self.msg_id_alloc.alloc(), + direction: crate::pdu::notify::Direction::ToClient, + })); + self.state = State::CapsExchanged; + Ok(resp) + } + ChanCreated(_chan_created_pdu) => { + if self.state != State::CapsExchanged { + return Err(pdu_other_err!("invalid state")); + } + resp.push(Box::new(InterfaceRelease { + msg_id: self.msg_id_alloc.alloc(), + iface_id: InterfaceId::NOTIFY_CLIENT.with_mask(Mask::Proxy), + })); + self.state = State::Ready; + Ok(resp) + } + AddChan(_add_channel_pdu) => { + if self.state != State::Ready { + return Err(pdu_other_err!("invalid state")); + } + self.backend.create_device_chan()?; + Ok(resp) + } + } + } +} + +impl_as_any!(UrbdrcControlServer); + +impl DvcServerProcessor for UrbdrcControlServer {} + +pub trait UrbdrcDeviceServerBackend: Send { + /// [Add Device Message][2.2.4.2]: + /// + /// After receiving the ADD_DEVICE message, the server creates a remote device instance that + /// represents the client-side physical device. + /// + /// [2.2.4.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a26bcb6d-d45d-48a9-b9bd-22e0107d8393 + fn add_device(&mut self, device: DeviceAnnounce) -> PduResult<()>; + + /// [Query Device Text Response Message][2.2.6.6]: + /// + /// Delivers the device description returned by the client to the server backend. + /// + /// [2.2.6.6]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/acffdcfa-c792-40a4-a8ee-c545ea5b0a38 + fn device_text(&mut self, device_text: DeviceText); + + /// [IO Control Completion Message][2.2.7.1]: + /// + /// Completes the IO control request identified by `request_id`. + /// + /// [2.2.7.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 + fn io_control_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + ) -> PduResult<()>; + + /// [IO Control Completion Message][2.2.7.1]: + /// + /// Completes the internal IO control request identified by `request_id`. + /// + /// [2.2.7.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 + fn internal_io_control_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + ) -> PduResult<()>; + + /// [URB Completion Message][2.2.7.2] and [URB Completion No Data Message][2.2.7.3]: + /// + /// Completes the transfer-in request identified by `request_id`. + /// + /// [2.2.7.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5bfa9c84-a74b-4942-9d09-e770b21081eb + /// [2.2.7.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/994fac8f-d258-47a6-aa35-48783abe49ec + fn transfer_in_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: TransferInCompletionResult, + ) -> PduResult<()>; + + /// [URB Completion No Data Message][2.2.7.3]: + /// + /// Completes the transfer-out request identified by `request_id`. + /// + /// [2.2.7.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/994fac8f-d258-47a6-aa35-48783abe49ec + fn transfer_out_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: TransferOutCompletionResult, + ) -> PduResult<()>; +} + +pub struct UrbdrcDeviceServer { + msg_alloc: IdAllocator, + request_id_alloc: RequestIdAllocator, + udev_iface: Option, + comp_iface: InterfaceId, + no_ack_isoch_write_jitter_buf_size: Option, + pending_io: BTreeMap, + backend: Box, +} + +enum Pending { + IoCtl { max_output_buf_size: u32 }, + InternalIoCtl { max_output_buf_size: u32 }, + TransferIn { max_output_buf_size: u32 }, + TransferOut { max_output_buf_size: u32 }, +} + +impl UrbdrcDeviceServer { + pub fn new( + backend: Box, + comp_iface: InterfaceId, + ) -> Result>> { + if u32::from(comp_iface) <= u32::from(InterfaceId::NOTIFY_SERVER) { + return Err(InvalidDeviceInterfaceId::new(backend)); + } + + Ok(Self { + msg_alloc: IdAllocator::new(), + request_id_alloc: RequestIdAllocator::new(), + udev_iface: None, + comp_iface, + no_ack_isoch_write_jitter_buf_size: None, + pending_io: BTreeMap::new(), + backend, + }) + } + + pub fn query_device_text(&mut self, text_type: u32, locale_id: u32) -> PduResult { + let udev_iface = self.usb_device_iface()?; + Ok(Box::new(QueryDeviceText { + msg_id: self.msg_alloc.alloc(), + udev_iface, + text_type, + locale_id, + })) + } + + /// [IO Control Message][2.2.6.3]: + /// + /// Builds an IO control request to be sent to the client-side physical device. + /// + /// [2.2.6.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/021733cb-8e3b-49ac-b3e3-f7a764b11141 + pub fn io_control(&mut self, io_control_packet: IoControlPacket) -> PduResult { + let udev_iface = self.usb_device_iface()?; + let request_id = self.request_id_alloc.alloc(); + let request = io_control_packet.into_pdu(self.msg_alloc.alloc(), request_id, udev_iface); + + request + .check_output_buffer_size() + .map_err(|_| pdu_other_err!("invalid IO_CONTROL output buffer size"))?; + + self.insert_pending_io( + request_id, + Pending::IoCtl { + max_output_buf_size: request.output_buffer_size, + }, + )?; + + Ok(ServerIoRequest { + request_id, + expects_completion: true, + message: Box::new(request), + }) + } + + /// [Internal IO Control Message][2.2.6.4]: + /// + /// Builds an internal IO control request to be sent to the client-side physical device. + /// + /// [2.2.6.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c3f3e320-336d-4d1b-84c9-51e0ed330ffe + pub fn internal_io_control( + &mut self, + internal_io_ctl_packet: InternalIoControlPacket, + ) -> PduResult { + let udev_iface = self.usb_device_iface()?; + let request_id = self.request_id_alloc.alloc(); + + // Currently, INTERNAL_IO_CONTROL is specified with an empty input buffer and a fixed 4-byte output buffer. + if !internal_io_ctl_packet.input_buffer.is_empty() { + return Err(pdu_other_err!("internal io control input buffer must be empty")); + } + if internal_io_ctl_packet.output_buffer_size != 4 { + return Err(pdu_other_err!("internal io control output buffer size must be 4")); + } + + let output_buffer_size = 4; + let request = internal_io_ctl_packet.into_pdu(self.msg_alloc.alloc(), request_id, udev_iface); + self.insert_pending_io( + request_id, + Pending::InternalIoCtl { + max_output_buf_size: output_buffer_size, + }, + )?; + + Ok(ServerIoRequest { + request_id, + expects_completion: true, + message: Box::new(request), + }) + } + + /// [Transfer In Request][2.2.6.7]: + /// + /// Builds a transfer request that reads data from the client-side physical device. + /// + /// [2.2.6.7]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/e40f7738-bdd3-480f-a8bb-e1557a83a151 + pub fn transfer_in(&mut self, request: TransferInPacket) -> PduResult { + let udev_iface = self.usb_device_iface()?; + let request_id = self.request_id_alloc.alloc(); + let output_buffer_size = request.output_buffer_size; + let ts_urb = request.ts_urb.into_ts_urb(request_id)?; + let pdu = TransferInRequest { + msg_id: self.msg_alloc.alloc(), + udev_iface, + ts_urb, + output_buffer_size, + }; + pdu.check_output_buffer_size() + .map_err(|_| pdu_other_err!("invalid TRANSFER_IN_REQUEST output buffer size"))?; + + self.insert_pending_io( + request_id, + Pending::TransferIn { + max_output_buf_size: output_buffer_size, + }, + )?; + + Ok(ServerIoRequest { + request_id, + expects_completion: true, + message: Box::new(pdu), + }) + } + + /// [Transfer Out Request][2.2.6.8]: + /// + /// Builds a transfer request that writes data to the client-side physical device. + /// + /// [2.2.6.8]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6d6c85b2-47bb-4674-975a-dc7d8ed684cd + pub fn transfer_out(&mut self, request: TransferOutPacket) -> PduResult { + let udev_iface = self.usb_device_iface()?; + let output_buffer_size = + u32::try_from(request.output_buffer.len()).map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; + + let request_id = self.request_id_alloc.alloc(); + let no_ack = request.ts_urb.no_ack; + let no_ack_isoch_write_jitter_buf_size = self + .no_ack_isoch_write_jitter_buf_size + .ok_or_else(|| pdu_other_err!("USB device capabilities uninitialized"))?; + let ts_urb = request + .ts_urb + .into_ts_urb(request_id, no_ack_isoch_write_jitter_buf_size)?; + let pdu = TransferOutRequest { + msg_id: self.msg_alloc.alloc(), + udev_iface, + ts_urb, + output_buffer: request.output_buffer, + }; + + if !no_ack { + self.insert_pending_io( + request_id, + Pending::TransferOut { + max_output_buf_size: output_buffer_size, + }, + )?; + } + + Ok(ServerIoRequest { + request_id, + expects_completion: !no_ack, + message: Box::new(pdu), + }) + } + + pub fn cancel_request(&mut self, request_id: RequestId) -> PduResult { + let udev_iface = self.usb_device_iface()?; + Ok(Box::new(CancelRequest { + msg_id: self.msg_alloc.alloc(), + udev_iface, + req_id: request_id, + })) + } + + pub fn retract_device(&mut self, reason: UsbRetractReason) -> PduResult { + let udev_iface = self.usb_device_iface()?; + self.pending_io.clear(); + self.no_ack_isoch_write_jitter_buf_size = None; + Ok(Box::new(RetractDevice { + msg_id: self.msg_alloc.alloc(), + udev_iface, + reason, + })) + } + + fn usb_device_iface(&self) -> PduResult { + self.udev_iface + .ok_or_else(|| pdu_other_err!("USB device uninitialized")) + } + + fn insert_pending_io(&mut self, request_id: RequestId, pending: Pending) -> PduResult<()> { + match self.pending_io.entry(request_id) { + Entry::Vacant(entry) => { + entry.insert(pending); + Ok(()) + } + Entry::Occupied(_) => Err(pdu_other_err!("request id collision")), + } + } + + fn handle_io_control_completion( + &mut self, + channel_id: u32, + completion: IoControlCompletion, + ) -> PduResult> { + if completion.completion_iface != self.comp_iface { + return Ok(Vec::new()); + } + + let IoControlCompletion { + request_id, + hresult, + information, + output_buffer_size, + output_buffer, + .. + } = completion; + + let Some(pending) = self.pending_io.remove(&request_id) else { + return Err(pdu_other_err!("completion mismatch")); + }; + + let (is_internal, max_output_buf_size) = match pending { + Pending::IoCtl { max_output_buf_size } => (false, max_output_buf_size), + Pending::InternalIoCtl { max_output_buf_size } => (true, max_output_buf_size), + Pending::TransferIn { .. } | Pending::TransferOut { .. } => { + return Err(pdu_other_err!("completion mismatch")); + } + }; + + if output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + + let result = IoControlCompletionResult { + hresult, + information, + output_buffer, + }; + + if is_internal { + self.backend + .internal_io_control_completed(channel_id, request_id, result)?; + } else { + self.backend.io_control_completed(channel_id, request_id, result)?; + } + + Ok(Vec::new()) + } + + fn handle_urb_completion(&mut self, channel_id: u32, completion: UrbCompletion) -> PduResult> { + if completion.completion_iface != self.comp_iface { + return Ok(Vec::new()); + } + + let request_id = RequestId::from(completion.req_id); + + let Some(Pending::TransferIn { max_output_buf_size }) = self.pending_io.remove(&request_id) else { + return Err(pdu_other_err!("completion mismatch")); + }; + + let output_buffer_size = + u32::try_from(completion.output_buffer.len()).map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; + if output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + + self.backend.transfer_in_completed( + channel_id, + request_id, + TransferInCompletionResult { + ts_urb_result: completion.ts_urb_result, + hresult: completion.hresult, + output_buffer: completion.output_buffer, + }, + )?; + + Ok(Vec::new()) + } + + fn handle_urb_completion_no_data( + &mut self, + channel_id: u32, + completion: UrbCompletionNoData, + ) -> PduResult> { + if completion.completion_iface != self.comp_iface { + return Ok(Vec::new()); + } + + let request_id = RequestId::from(completion.req_id); + let Some(pending) = self.pending_io.remove(&request_id) else { + return Err(pdu_other_err!("completion mismatch")); + }; + + let is_transfer_out = match pending { + Pending::TransferIn { .. } => { + if completion.output_buffer_size != 0 { + return Err(pdu_other_err!("output buffer size must be zero")); + } + false + } + Pending::TransferOut { max_output_buf_size } => { + if completion.output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + true + } + Pending::IoCtl { .. } | Pending::InternalIoCtl { .. } => { + return Err(pdu_other_err!("completion mismatch")); + } + }; + + if is_transfer_out { + self.backend.transfer_out_completed( + channel_id, + request_id, + TransferOutCompletionResult { + ts_urb_result: completion.ts_urb_result, + hresult: completion.hresult, + output_buffer_size: completion.output_buffer_size, + }, + )?; + } else { + self.backend.transfer_in_completed( + channel_id, + request_id, + TransferInCompletionResult { + ts_urb_result: completion.ts_urb_result, + hresult: completion.hresult, + output_buffer: Vec::new(), + }, + )?; + } + + Ok(Vec::new()) + } +} + +impl DvcProcessor for UrbdrcDeviceServer { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(vec![Box::new(ChannelCreated { + msg_id: self.msg_alloc.alloc(), + direction: crate::pdu::notify::Direction::ToClient, + })]) + } + + fn process(&mut self, channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = UrbdrcClientDevicePdu::decode(&mut ReadCursor::new(payload)).map_err(|e| decode_err!(e))?; + let mut resp: Vec = Vec::new(); + + use UrbdrcClientDevicePdu::*; + match pdu { + ChanCreated(_channel_created_pdu) => { + resp.push(Box::new(InterfaceRelease { + msg_id: self.msg_alloc.alloc(), + iface_id: InterfaceId::NOTIFY_CLIENT.with_mask(Mask::Proxy), + })); + Ok(resp) + } + AddDev(add_dev_pdu) => { + // In the case of the server receiving a duplicate interface ID, the server MUST + // ignore the ADD_DEVICE message. + if self.udev_iface.is_some() { + return Ok(resp); + } + let udev_iface = add_dev_pdu.usb_device; + let no_ack_isoch_write_jitter_buf_size = add_dev_pdu.usb_device_caps.no_ack_isoch_write_jitter_buf_size; + self.udev_iface = Some(udev_iface); + + let device = add_dev_pdu.try_into()?; + + self.backend.add_device(device)?; + self.no_ack_isoch_write_jitter_buf_size = Some(no_ack_isoch_write_jitter_buf_size); + resp.push(Box::new(InterfaceRelease { + msg_id: self.msg_alloc.alloc(), + iface_id: InterfaceId::DEVICE_SINK.with_mask(Mask::Proxy), + })); + resp.push(Box::new(RegisterRequestCallback { + msg_id: self.msg_alloc.alloc(), + udev_iface, + request_completion: Some(self.comp_iface), + })); + Ok(resp) + } + IfaceRelease(_iface_release_pdu) => Ok(resp), + DevTextRsp(dev_text_rsp_pdu) => { + let device_text = DeviceText { + hresult: dev_text_rsp_pdu.hresult, + description: dev_text_rsp_pdu + .device_description + .into_native() + .map_err(|e| pdu_other_err!("invalid device description").with_source(e))?, + }; + self.backend.device_text(device_text); + Ok(resp) + } + IoctlComp(ioctl_comp_pdu) => self.handle_io_control_completion(channel_id, ioctl_comp_pdu), + UrbComp(urb_comp_pdu) => self.handle_urb_completion(channel_id, urb_comp_pdu), + UrbCompNoData(urb_comp_no_data_pdu) => self.handle_urb_completion_no_data(channel_id, urb_comp_no_data_pdu), + QueryIfaceReq(query_iface_req_pdu) => { + resp.push(Box::new(QueryInterfaceFailureResponse { + msg_id: query_iface_req_pdu.msg_id, + iface_id: query_iface_req_pdu.iface_id, + })); + Ok(resp) + } + } + } +} + +impl_as_any!(UrbdrcDeviceServer); + +impl DvcServerProcessor for UrbdrcDeviceServer {} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs index 5f51e3d99b..8e4ac16183 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs @@ -6,16 +6,14 @@ use ironrdp_dvc::{DvcChannelListener as _, DvcMessage, DvcProcessor as _}; use ironrdp_pdu::PduResult; use ironrdp_rdpeusb::CHANNEL_NAME; use ironrdp_rdpeusb::client::{ - DeviceInfo, DeviceManagerBackend, DeviceText, IoControlResponse, UrbInResponse, UrbOutResponse, - UrbdrcControlClient, UrbdrcDeviceBackend, UrbdrcDeviceClient, UrbdrcListener, + DeviceManagerBackend, UrbdrcControlClient, UrbdrcDeviceBackend, UrbdrcDeviceClient, UrbdrcListener, }; +use ironrdp_rdpeusb::io::*; use ironrdp_rdpeusb::pdu::caps::{Capability, RimExchangeCapabilityRequest}; use ironrdp_rdpeusb::pdu::header::InterfaceId; use ironrdp_rdpeusb::pdu::iface_manipulation::InterfaceRelease; use ironrdp_rdpeusb::pdu::notify::{ChannelCreated, Direction}; use ironrdp_rdpeusb::pdu::sink::AddVirtualChannel; -use ironrdp_rdpeusb::pdu::usb_dev::{InternalIoControl, IoControl, TransferInRequest, TransferOutRequest}; -use ironrdp_rdpeusb::pdu::utils::RequestId; use ironrdp_rdpeusb::pdu::{ UrbdrcClientControlPdu, UrbdrcClientDevicePdu, UrbdrcServerControlPdu, UrbdrcServerDevicePdu, }; @@ -113,8 +111,8 @@ impl UrbdrcDeviceBackend for TestDeviceBackend { &mut self, _channel_id: u32, _request_id: RequestId, - _request: IoControl, - ) -> PduResult> { + _request: IoControlPacket, + ) -> PduResult> { Ok(None) } @@ -122,8 +120,8 @@ impl UrbdrcDeviceBackend for TestDeviceBackend { &mut self, _channel_id: u32, _request_id: RequestId, - _request: InternalIoControl, - ) -> PduResult> { + _request: InternalIoControlPacket, + ) -> PduResult> { Ok(None) } @@ -131,8 +129,8 @@ impl UrbdrcDeviceBackend for TestDeviceBackend { &mut self, _channel_id: u32, _request_id: RequestId, - _request: TransferInRequest, - ) -> PduResult> { + _request: TransferInPacket, + ) -> PduResult> { Ok(None) } @@ -140,11 +138,20 @@ impl UrbdrcDeviceBackend for TestDeviceBackend { &mut self, _channel_id: u32, _request_id: RequestId, - _request: TransferOutRequest, - ) -> PduResult> { + _request: TransferOutPacket, + ) -> PduResult> { Ok(None) } + fn transfer_out_no_ack( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: TransferOutPacket, + ) -> PduResult<()> { + Ok(()) + } + fn retract(&mut self, _channel_id: u32) -> PduResult<()> { Ok(()) } diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs index b1cb5c2392..01c8f5c940 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs @@ -1,5 +1,5 @@ use ironrdp_core::encode_vec; -use ironrdp_rdpeusb::client::{ +use ironrdp_rdpeusb::io::device::{ DeviceInfo, UsbBcdVersion, UsbClassCodes, UsbConfigInfo, UsbConnectionSpeed, UsbDeviceDescriptorInfo, UsbDeviceLocation, UsbInterfaceInfo, add_device_from_info, }; diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs index 2fceceb098..9741b9b5c1 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs @@ -1,4 +1,4 @@ -use ironrdp_rdpeusb::client::{ +use ironrdp_rdpeusb::io::device::{ DeviceInfo, UsbBcdVersion, UsbClassCodes, UsbConfigInfo, UsbConnectionSpeed, UsbDeviceDescriptorInfo, UsbDeviceLocation, UsbInterfaceInfo, }; From d767d990325448bf3385974da7ea9b6dcc477673 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 1 Jul 2026 09:19:35 -0500 Subject: [PATCH 306/325] feat(tls): make the rustls crypto provider selectable (#1387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the ironrdp-tls rustls backend’s crypto provider selectable at compile time by restructuring Cargo features, avoiding forcing a single provider onto downstreams via tokio-rustls default features. --- crates/ironrdp-tls/Cargo.toml | 13 +++++++++++-- crates/ironrdp-tls/README.md | 17 ++++++++++++++--- crates/ironrdp-tls/src/lib.rs | 14 ++++++++------ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/ironrdp-tls/Cargo.toml b/crates/ironrdp-tls/Cargo.toml index 578f3a745d..8ad4440edb 100644 --- a/crates/ironrdp-tls/Cargo.toml +++ b/crates/ironrdp-tls/Cargo.toml @@ -18,7 +18,16 @@ test = false [features] default = [] # No default feature, the user must choose a TLS backend by enabling the appropriate feature. -rustls = ["dep:tokio-rustls", "dep:x509-cert", "tokio/io-util"] +# The rustls backend. `rustls` keeps using the aws-lc-rs crypto provider (unchanged +# default); the crypto provider is otherwise selectable so downstream crates are not +# forced onto a specific one. +rustls = ["rustls-aws-lc-rs"] +rustls-aws-lc-rs = ["rustls-no-provider", "tokio-rustls/aws_lc_rs"] +rustls-ring = ["rustls-no-provider", "tokio-rustls/ring"] +# rustls backend without a bundled crypto provider: the downstream must install a +# rustls CryptoProvider as the process default before opening a connection, otherwise +# building the client configuration panics. +rustls-no-provider = ["dep:tokio-rustls", "dep:x509-cert", "tokio/io-util", "tokio-rustls/logging", "tokio-rustls/tls12"] native-tls = ["dep:tokio-native-tls", "dep:x509-cert", "tokio/io-util"] stub = [] @@ -26,7 +35,7 @@ stub = [] tokio = { version = "1.52" } x509-cert = { version = "0.2", default-features = false, features = ["std"], optional = true } # public tokio-native-tls = { version = "0.3", optional = true } # public -tokio-rustls = { version = "0.26", optional = true } # public +tokio-rustls = { version = "0.26", default-features = false, optional = true } # public [lints] workspace = true diff --git a/crates/ironrdp-tls/README.md b/crates/ironrdp-tls/README.md index f1f0b2bc7c..9ef9b84d2a 100644 --- a/crates/ironrdp-tls/README.md +++ b/crates/ironrdp-tls/README.md @@ -2,16 +2,27 @@ TLS boilerplate common with most IronRDP clients. -This crate exposes three features for selecting the TLS backend: +This crate exposes features for selecting the TLS backend: -- `rustls`: use the rustls crate. +- `rustls`: use the rustls crate (with the default aws-lc-rs crypto provider). - `native-tls`: use the native-tls crate. - `stub`: use a stubbed backend which fail at runtime when used. -These features are mutually exclusive and only one may be enabled at a time. +These backends are mutually exclusive and only one may be enabled at a time. When more than one backend is enabled, a compile-time error is emitted. For this reason, no feature is enabled by default. +When the rustls backend is used, its crypto provider is selectable so downstream +crates are not forced onto a specific one: + +- `rustls` or `rustls-aws-lc-rs`: the aws-lc-rs provider. `rustls` is an alias for + `rustls-aws-lc-rs`, so the default backend is unchanged. +- `rustls-ring`: the ring provider. +- `rustls-no-provider`: no provider is bundled. The downstream must install a rustls + `CryptoProvider` as the process default before opening a connection, otherwise + building the client configuration panics. Use this to plug in a custom or pure-Rust + provider. + The rationale is two-fold: - It makes deliberate the choice of the TLS backend. diff --git a/crates/ironrdp-tls/src/lib.rs b/crates/ironrdp-tls/src/lib.rs index 6da259fd53..1ab522bbcf 100644 --- a/crates/ironrdp-tls/src/lib.rs +++ b/crates/ironrdp-tls/src/lib.rs @@ -1,7 +1,7 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] -#[cfg(feature = "rustls")] +#[cfg(feature = "rustls-no-provider")] #[path = "rustls.rs"] mod impl_; @@ -14,15 +14,17 @@ mod impl_; mod impl_; #[cfg(any( - not(any(feature = "stub", feature = "native-tls", feature = "rustls")), + not(any(feature = "stub", feature = "native-tls", feature = "rustls-no-provider")), all(feature = "stub", feature = "native-tls"), - all(feature = "stub", feature = "rustls"), - all(feature = "rustls", feature = "native-tls"), + all(feature = "stub", feature = "rustls-no-provider"), + all(feature = "rustls-no-provider", feature = "native-tls"), ))] -compile_error!("a TLS backend must be selected by enabling a single feature out of: `rustls`, `native-tls`, `stub`"); +compile_error!( + "a TLS backend must be selected by enabling a single feature out of: `rustls`, `native-tls`, `stub` (the rustls crypto provider is chosen via `rustls`/`rustls-aws-lc-rs`/`rustls-ring`/`rustls-no-provider`)" +); // The whole public API of this crate. -#[cfg(any(feature = "stub", feature = "native-tls", feature = "rustls"))] +#[cfg(any(feature = "stub", feature = "native-tls", feature = "rustls-no-provider"))] pub use impl_::{TlsStream, negotiated, upgrade}; /// TLS parameters negotiated during the handshake, to the extent the active From 5ca84a5724f48093193e39a3097c4f4987d64bbe Mon Sep 17 00:00:00 2001 From: clintcan Date: Wed, 1 Jul 2026 22:58:25 +0800 Subject: [PATCH 307/325] feat(acceptor): expose the client's keyboard layout on AcceptorResult (#1397) --- crates/ironrdp-acceptor/src/connection.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ironrdp-acceptor/src/connection.rs b/crates/ironrdp-acceptor/src/connection.rs index 2a9be03145..3015c05263 100644 --- a/crates/ironrdp-acceptor/src/connection.rs +++ b/crates/ironrdp-acceptor/src/connection.rs @@ -31,6 +31,7 @@ pub struct Acceptor { user_channel_id: u16, message_channel_id: Option, desktop_size: DesktopSize, + keyboard_layout: u32, server_capabilities: Vec, static_channels: StaticChannelSet, saved_for_reactivation: AcceptorState, @@ -54,6 +55,14 @@ pub struct AcceptorResult { /// channel. `None` when the client did not request it. pub message_channel_id: Option, pub reactivation: bool, + /// Keyboard layout identifier (KLID) announced by the client in its GCC + /// Client Core Data (section 2.2.1.3.2, `keyboardLayout`). + /// + /// This is the low word of a Windows locale identifier (e.g. `0x0000_0409` + /// for US English, `0x0000_040C` for French). `0` when the client did not + /// announce one. Servers can use it to pick a server-side keyboard layout + /// matching the client without changing any local input state. + pub keyboard_layout: u32, /// Credentials received from the client during SecureSettingsExchange. /// /// Present for TLS-mode connections where the client sends credentials @@ -79,6 +88,7 @@ impl Acceptor { io_channel_id: IO_CHANNEL_ID, message_channel_id: None, desktop_size, + keyboard_layout: 0, server_capabilities: capabilities, static_channels: StaticChannelSet::new(), saved_for_reactivation: Default::default(), @@ -122,6 +132,7 @@ impl Acceptor { io_channel_id: consumed.io_channel_id, message_channel_id: consumed.message_channel_id, desktop_size, + keyboard_layout: consumed.keyboard_layout, server_capabilities: consumed.server_capabilities, static_channels, saved_for_reactivation, @@ -181,6 +192,7 @@ impl Acceptor { user_channel_id: self.user_channel_id, io_channel_id: self.io_channel_id, message_channel_id: self.message_channel_id, + keyboard_layout: self.keyboard_layout, reactivation: self.reactivation, credentials: self.received_credentials.take(), }), @@ -438,6 +450,7 @@ impl Sequence for Acceptor { let gcc_blocks = settings_initial.conference_create_request.into_gcc_blocks(); let early_capability = gcc_blocks.core.optional_data.early_capability_flags; let client_wants_message_channel = gcc_blocks.message_channel.is_some(); + self.keyboard_layout = gcc_blocks.core.keyboard_layout; let joined: Vec<_> = gcc_blocks .network From d471bd066f303df22f4767801fd97ecdbf527869 Mon Sep 17 00:00:00 2001 From: clintcan Date: Thu, 2 Jul 2026 12:08:21 +0800 Subject: [PATCH 308/325] feat(acceptor): honor the client-requested desktop size (#1373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in server/acceptor knob to negotiate the RDP session desktop size using the client’s originally requested resolution (from GCC Client Core Data) so the server can start at the client’s native size without a Deactivation–Reactivation resize round trip. Co-authored-by: Clint Christopher Canada --- crates/ironrdp-acceptor/src/connection.rs | 100 ++++++++++++++++++++-- crates/ironrdp-server/src/builder.rs | 35 ++++++++ crates/ironrdp-server/src/server.rs | 8 ++ 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/crates/ironrdp-acceptor/src/connection.rs b/crates/ironrdp-acceptor/src/connection.rs index 3015c05263..5c8d7dbd1f 100644 --- a/crates/ironrdp-acceptor/src/connection.rs +++ b/crates/ironrdp-acceptor/src/connection.rs @@ -38,6 +38,39 @@ pub struct Acceptor { pub(crate) creds: Option, received_credentials: Option, reactivation: bool, + honor_client_desktop_size: bool, +} + +/// Minimum and maximum desktop dimension honored from a client. +/// +/// A desktop dimension in RDP is a `u16`; [MS-RDPBCGR] caps it at 8192, and +/// 200 is a conservative floor. A client-requested dimension outside this +/// range is not honored: the acceptor keeps the server-provided desktop size +/// rather than treating the request as an error. +const MIN_DESKTOP_DIM: u16 = 200; +const MAX_DESKTOP_DIM: u16 = 8192; + +/// Returns the client-requested desktop size if both dimensions are within the +/// protocol-legal range, otherwise `None`. +fn validate_desktop_size(width: u16, height: u16) -> Option { + if (MIN_DESKTOP_DIM..=MAX_DESKTOP_DIM).contains(&width) && (MIN_DESKTOP_DIM..=MAX_DESKTOP_DIM).contains(&height) { + Some(DesktopSize { width, height }) + } else { + None + } +} + +/// Writes `size` into every Bitmap capability set in `capabilities`. +/// +/// The server advertises its desktop size in the Bitmap capability set of the +/// Demand Active PDU; this keeps that advertisement in sync with `size`. +fn set_bitmap_desktop_size(capabilities: &mut [CapabilitySet], size: DesktopSize) { + for cap in capabilities.iter_mut() { + if let CapabilitySet::Bitmap(cap) = cap { + cap.desktop_width = size.width; + cap.desktop_height = size.height; + } + } } #[derive(Debug)] @@ -95,9 +128,42 @@ impl Acceptor { creds, received_credentials: None, reactivation: false, + honor_client_desktop_size: false, } } + /// Adopt the desktop size requested by the client in its Client Core Data + /// instead of the size this acceptor was constructed with. + /// + /// The client's requested resolution is only carried in the GCC Client + /// Core Data of the MCS Connect Initial PDU; the desktop size echoed back + /// later in the client's Confirm Active is, per [MS-RDPBCGR] 2.2.1.13.2, + /// the value the client copied from the *server's* Demand Active, so it + /// cannot be used to discover what the client originally asked for. When + /// this is enabled and the client's request is within the protocol-legal + /// range, the acceptor negotiates that size from the start (it is written + /// into the server's Bitmap capability set before Demand Active is sent), + /// avoiding a Deactivation-Reactivation resize round trip. + /// + /// Disabled by default, preserving the previous behavior of always + /// enforcing the server-provided size. + /// + /// # Precondition + /// + /// Enabling this only makes sense together with a display handler + /// ([`RdpServerDisplay`]) whose `request_initial_size` actually adopts (or + /// at least intersects) the size it is given. The acceptor negotiates the + /// client's size, but the server still builds its framebuffer/encoder from + /// the size the display handler reports; if that handler ignores the + /// requested size and returns a fixed, smaller framebuffer, the resulting + /// mismatch can cause the client to be dropped. With a fixed-size display + /// handler, leave this disabled. + /// + /// [`RdpServerDisplay`]: + pub fn set_honor_client_desktop_size(&mut self, honor: bool) { + self.honor_client_desktop_size = honor; + } + pub fn new_deactivation_reactivation( mut consumed: Acceptor, static_channels: StaticChannelSet, @@ -111,12 +177,7 @@ impl Acceptor { return Err(general_err!("invalid acceptor state")); }; - for cap in consumed.server_capabilities.iter_mut() { - if let CapabilitySet::Bitmap(cap) = cap { - cap.desktop_width = desktop_size.width; - cap.desktop_height = desktop_size.height; - } - } + set_bitmap_desktop_size(&mut consumed.server_capabilities, desktop_size); let state = AcceptorState::CapabilitiesSendServer { early_capability, channels: channels.clone(), @@ -139,6 +200,7 @@ impl Acceptor { creds: consumed.creds, received_credentials: consumed.received_credentials, reactivation: true, + honor_client_desktop_size: consumed.honor_client_desktop_size, }) } @@ -452,6 +514,32 @@ impl Sequence for Acceptor { let client_wants_message_channel = gcc_blocks.message_channel.is_some(); self.keyboard_layout = gcc_blocks.core.keyboard_layout; + // Adopt the client's requested desktop size (from its Client + // Core Data) before Demand Active is sent, so the session is + // negotiated at that size without a Deactivation-Reactivation + // resize. See `set_honor_client_desktop_size`. + if self.honor_client_desktop_size { + if let Some(client_size) = + validate_desktop_size(gcc_blocks.core.desktop_width, gcc_blocks.core.desktop_height) + { + if client_size != self.desktop_size { + debug!( + requested = ?client_size, + previous = ?self.desktop_size, + "Honoring client-requested desktop size" + ); + self.desktop_size = client_size; + set_bitmap_desktop_size(&mut self.server_capabilities, client_size); + } + } else { + debug!( + width = gcc_blocks.core.desktop_width, + height = gcc_blocks.core.desktop_height, + "Client requested an out-of-range desktop size; keeping the server-provided size" + ); + } + } + let joined: Vec<_> = gcc_blocks .network .map(|network| { diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index f9c52d9d8a..0795e6001f 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -42,6 +42,7 @@ pub struct BuilderDone { gfx_factory: Option>, display_suppressed: Option>, autodetect_rtt: Option>, + honor_client_desktop_size: bool, } pub struct RdpServerBuilder { @@ -142,6 +143,7 @@ impl RdpServerBuilder { gfx_factory: None, display_suppressed: None, autodetect_rtt: None, + honor_client_desktop_size: false, }, } } @@ -163,6 +165,7 @@ impl RdpServerBuilder { gfx_factory: None, display_suppressed: None, autodetect_rtt: None, + honor_client_desktop_size: false, }, } } @@ -229,6 +232,37 @@ impl RdpServerBuilder { self } + /// Negotiate each session at the desktop size the client requests in its + /// Client Core Data, rather than the size reported by the display handler. + /// + /// The client's requested resolution is only carried in the GCC Client + /// Core Data of the connection handshake; the size echoed back in the + /// client's Confirm Active is the value it copied from the server's Demand + /// Active (per [MS-RDPBCGR] 2.2.1.13.2) and so cannot reveal what the + /// client asked for. With this enabled the acceptor adopts the requested + /// size (when within the protocol-legal range) before Demand Active is + /// sent, so the session starts at that size with no Deactivation- + /// Reactivation resize. The display handler observes the negotiated size + /// through [`RdpServerDisplay::request_initial_size`]. + /// + /// Defaults to `false`, enforcing the size reported by the display handler. + /// + /// # Precondition + /// + /// Only enable this with a [`RdpServerDisplay`] whose + /// [`request_initial_size`] actually adopts (or at least intersects) the + /// size it is given: the acceptor negotiates the client's size, but the + /// server still builds its framebuffer/encoder from the size the display + /// handler reports. A fixed-size handler that ignores the requested size + /// can produce a mismatch that drops the client. Leave this disabled when + /// the display handler serves a fixed framebuffer. + /// + /// [`request_initial_size`]: crate::RdpServerDisplay::request_initial_size + pub fn with_honor_client_desktop_size(mut self, honor: bool) -> Self { + self.state.honor_client_desktop_size = honor; + self + } + /// Set a credential validator for TLS-mode connections. /// /// When set, credentials received from the client during @@ -262,6 +296,7 @@ impl RdpServerBuilder { security: self.state.security, codecs: self.state.codecs, max_request_size: self.state.max_request_size, + honor_client_desktop_size: self.state.honor_client_desktop_size, }, self.state.handler, self.state.display, diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index e987005201..00aa2156c4 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -218,11 +218,18 @@ impl CredentialValidator for ExactMatchCredentialValidator { } #[derive(Clone)] +#[non_exhaustive] pub struct RdpServerOptions { pub addr: SocketAddr, pub security: RdpServerSecurity, pub codecs: BitmapCodecs, pub max_request_size: u32, + /// When `true`, each connection's acceptor adopts the desktop size the + /// client requests in its Client Core Data (instead of the size reported + /// by the display handler), negotiating that size from the start without a + /// Deactivation-Reactivation resize. Defaults to `false`. Set via + /// [`RdpServerBuilder::with_honor_client_desktop_size`](crate::RdpServerBuilder::with_honor_client_desktop_size). + pub honor_client_desktop_size: bool, } impl RdpServerOptions { @@ -711,6 +718,7 @@ impl RdpServer { let size = self.display.lock().await.size().await; let capabilities = capabilities::capabilities(&self.opts, size); let mut acceptor = Acceptor::new(self.opts.security.flag(), size, capabilities, self.creds.clone()); + acceptor.set_honor_client_desktop_size(self.opts.honor_client_desktop_size); self.attach_channels(&mut acceptor); From 18bf75c7b3442881b42ee79b5f530ca97ab391ed Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Wed, 1 Jul 2026 23:59:45 -0500 Subject: [PATCH 309/325] feat(server): accept connections with TLS terminated at a lower layer (#1281) Adds a way to run a single RDP connection over a byte stream whose confidentiality is already provided by the embedder's transport, rather than having ironrdp-server perform the inner TLS handshake itself when X.224 selects PROTOCOL_SSL. --- crates/ironrdp-server/src/lib.rs | 2 +- crates/ironrdp-server/src/server.rs | 214 ++++++++++++++++++++++------ 2 files changed, 170 insertions(+), 46 deletions(-) diff --git a/crates/ironrdp-server/src/lib.rs b/crates/ironrdp-server/src/lib.rs index 4ae6b71679..505d07a2f7 100644 --- a/crates/ironrdp-server/src/lib.rs +++ b/crates/ironrdp-server/src/lib.rs @@ -35,7 +35,7 @@ pub use helper::TlsIdentityCtx; pub use server::{ ConnectionHandler, CredentialDecision, CredentialValidationError, CredentialValidator, Credentials, ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, RdpServerSecurity, ServerEvent, - ServerEventSender, + ServerEventSender, TransportTls, }; pub use sound::{RdpsndServerHandler, RdpsndServerMessage, SoundServerFactory}; diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 00aa2156c4..acdfe99dce 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -356,6 +356,20 @@ impl DisplayControlHandler for DisplayControlBackend { } } +/// Selects who performs the TLS handshake for a connection accepted via +/// [`RdpServer::run_connection_with`]. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum TransportTls { + /// IronRDP performs the TLS accept on the stream (standard TCP+TLS). + Managed, + /// The stream is already past TLS, terminated by a lower layer (e.g. a WSS + /// terminator). IronRDP skips the TLS handshake. The caller MUST guarantee + /// the transport is already encrypted; see the preconditions on + /// [`RdpServer::run_connection_with`]. + AlreadyDone, +} + /// RDP Server /// /// A server is created to listen for connections. @@ -697,20 +711,101 @@ impl RdpServer { acceptor.attach_static_channel(dvc); } + /// Run a single RDP connection over `stream`, performing the + /// IronRDP-managed TLS handshake on `ShouldUpgrade` (standard TCP+TLS). + /// + /// Equivalent to [`run_connection_with`](Self::run_connection_with) with + /// [`TransportTls::Managed`]. pub async fn run_connection(&mut self, stream: S) -> Result<()> + where + S: AsyncRead + AsyncWrite + Send + Sync + Unpin, + { + self.run_connection_with(stream, TransportTls::Managed).await + } + + /// Run a single RDP connection over `stream`, choosing who performs the TLS + /// handshake with `tls`. + /// + /// With [`TransportTls::Managed`], IronRDP performs the TLS accept on + /// `ShouldUpgrade`, exactly as [`run_connection`](Self::run_connection). + /// + /// With [`TransportTls::AlreadyDone`], the caller's `stream` has ALREADY + /// been transport-encrypted at a lower layer that the embedder owns + /// (typically a WSS terminator in the same process, or a TLS stream the + /// embedder accepted up front), so IronRDP skips the TLS handshake and + /// advances the state machine via [`Acceptor::mark_security_upgrade_as_done`]. + /// Everything past the handshake, including the optional Hybrid CredSSP + /// exchange and finalization, is identical to the managed path. + /// + /// # Use case for [`TransportTls::AlreadyDone`] + /// + /// This mode decouples transport encryption from the RDP security-upgrade + /// step. It is for ironrdp-server endpoints that terminate transport + /// encryption themselves before the RDP state machine runs — for example a + /// server that accepts WSS directly, or one fronted by an in-process TLS + /// terminator — and therefore must not perform a second, inner TLS + /// handshake when the X.224 negotiation selects `PROTOCOL_SSL`. + /// + /// This is distinct from a [RDCleanPath] proxy deployment (e.g. + /// Devolutions Gateway), where the proxy performs a real TLS handshake with + /// a *separate* backend RDP server and relays that server's certificate + /// chain to the client. In that topology the backend server owns its own + /// TLS and uses [`TransportTls::Managed`]; this mode does not apply to it. + /// RDCleanPath is relevant here only as one client-side mechanism (see + /// precondition 2) for telling a client not to expect an inner handshake. + /// + /// # Preconditions for [`TransportTls::AlreadyDone`] (caller MUST guarantee) + /// + /// 1. The `stream` is already transport-encrypted by another layer + /// (WSS, in-process, etc.). Passing a plain TCP stream here exposes + /// RDP traffic in plaintext on the wire. + /// + /// 2. The connecting client must not expect an inner TLS handshake on this + /// stream. Vanilla RDP clients (mstsc, xfreerdp) negotiate TLS from the + /// X.224 `selectedProtocol` and have no concept of "TLS already done at a + /// lower layer": they will hang or fail, and must use + /// [`TransportTls::Managed`]. Arranging for a client to skip the inner + /// handshake is the embedder's responsibility; RDCleanPath is one such + /// mechanism, but this method does not depend on it. + /// + /// 3. If `self.opts.security` is [`RdpServerSecurity::Hybrid`], two things + /// must hold. First, the client must support CredSSP over this + /// transport; the SPNEGO exchange itself is transport-independent + /// (CredSSP carries its own crypto via TSRequest), so it runs the same + /// as on the managed path. Second, and less obvious: the CredSSP + /// server-public-key confirmation (`pubKeyAuth`, per MS-CSSP) binds to + /// the certificate the client validated at the lower transport layer, + /// not to anything IronRDP does here. So the public key configured in + /// [`RdpServerSecurity::Hybrid`] MUST be the public key of the + /// certificate that lower layer (e.g. the WSS terminator) presented to + /// the client, otherwise the client's `pubKeyAuth` check fails and + /// Hybrid is rejected. This is the embedder's responsibility; it does + /// not hold automatically. In practice it means terminating transport + /// TLS with the same certificate configured for Hybrid. + /// + /// [RDCleanPath]: https://docs.rs/ironrdp-rdcleanpath + /// + /// # Wire-level invariant + /// + /// This method does NOT alter the X.224 negotiation. The acceptor still + /// advertises whatever `SecurityProtocol` it was constructed with, and the + /// connecting client still negotiates as normal. The only behaviour change + /// under [`TransportTls::AlreadyDone`] is that after the negotiation reaches + /// the security-upgrade gate, no TLS handshake is performed on the byte + /// stream, because the caller's stream is already past TLS at a lower layer. + pub async fn run_connection_with(&mut self, stream: S, tls: TransportTls) -> Result<()> where S: AsyncRead + AsyncWrite + Send + Sync + Unpin, { // Per-connection state must start fresh: if the previous client // disconnected while it had sent `SuppressOutput { None }` (e.g., // closed the mstsc window while minimized so the matching resume - // PDU never arrived), the flag would still read `true` here and - // the display backend would silently drop frames for the entire - // new session until/unless the new client happens to send a + // PDU never arrived), the flag would still read `true` here and the + // display backend would silently drop frames for the entire new + // session until/unless the new client happens to send a // `RefreshRectangle` or `SuppressOutput { Some(rect) }`. Resetting - // here also covers backends that share an externally-created Arc - // via `set_display_suppressed_handle()` — they get the same - // per-connection clean slate. + // here also covers backends that share an externally-created Arc via + // `set_display_suppressed_handle()`. self.display_suppressed.store(false, Ordering::Relaxed); let framed = TokioFramed::new(stream); @@ -727,47 +822,33 @@ impl RdpServer { .context("accept_begin failed")?; match res { - BeginResult::ShouldUpgrade(stream) => { - let tls_acceptor = match &self.opts.security { - RdpServerSecurity::Tls(acceptor) => acceptor, - RdpServerSecurity::Hybrid((acceptor, _)) => acceptor, - RdpServerSecurity::None => unreachable!(), - }; - let accept = match tls_acceptor.accept(stream).await { - Ok(accept) => accept, - Err(e) => { - warn!("Failed to TLS accept: {}", e); - return Ok(()); - } - }; - let mut framed = TokioFramed::new(accept); - - acceptor.mark_security_upgrade_as_done(); - - if let RdpServerSecurity::Hybrid((_, pub_key)) = &self.opts.security { - // Generic streams don't expose peer address. Use a neutral - // placeholder; it's unclear whether CredSSP/NTLM actually - // uses this value in practice. - let client_name = "rdp-client".to_owned(); - - ironrdp_acceptor::accept_credssp( - &mut framed, - &mut acceptor, - &mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(), - client_name.into(), - pub_key.clone(), - None, - ) - .await?; + // The only thing that varies between the two modes is who performs + // the TLS handshake; everything past it is `finalize_after_upgrade`. + BeginResult::ShouldUpgrade(stream) => match tls { + TransportTls::Managed => { + let tls_acceptor = match &self.opts.security { + RdpServerSecurity::Tls(acceptor) => acceptor, + RdpServerSecurity::Hybrid((acceptor, _)) => acceptor, + RdpServerSecurity::None => unreachable!(), + }; + let accept = match tls_acceptor.accept(stream).await { + Ok(accept) => accept, + Err(e) => { + warn!("Failed to TLS accept: {}", e); + return Ok(()); + } + }; + self.finalize_after_upgrade(TokioFramed::new(accept), acceptor, "TLS connection") + .await?; } - - let framed = self.accept_finalize(framed, acceptor).await?; - debug!("Shutting down TLS connection"); - let (mut tls_stream, _) = framed.into_inner(); - if let Err(e) = tls_stream.shutdown().await { - debug!(?e, "TLS shutdown error"); + TransportTls::AlreadyDone => { + // The stream is already past TLS (terminated at a lower + // layer, e.g. a WSS terminator); do NOT call + // tls_acceptor.accept on it. + self.finalize_after_upgrade(TokioFramed::new(stream), acceptor, "TLS-offloaded stream") + .await?; } - } + }, BeginResult::Continue(framed) => { self.accept_finalize(framed, acceptor).await?; @@ -777,6 +858,49 @@ impl RdpServer { Ok(()) } + /// Shared post-handshake tail for both [`TransportTls`] modes: mark the + /// security upgrade complete, run the optional Hybrid CredSSP exchange, + /// finalize, and shut the stream down. Single-sourcing this is what keeps + /// the managed and TLS-offloaded paths structurally identical past the + /// handshake, so per-connection state handling cannot drift between them. + async fn finalize_after_upgrade( + &mut self, + mut framed: TokioFramed, + mut acceptor: Acceptor, + shutdown_label: &str, + ) -> Result<()> + where + S: AsyncRead + AsyncWrite + Sync + Send + Unpin, + { + acceptor.mark_security_upgrade_as_done(); + + if let RdpServerSecurity::Hybrid((_, pub_key)) = &self.opts.security { + // Generic streams don't expose peer address. Use a neutral + // placeholder; it's unclear whether CredSSP/NTLM actually + // uses this value in practice. + let client_name = "rdp-client".to_owned(); + + ironrdp_acceptor::accept_credssp( + &mut framed, + &mut acceptor, + &mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(), + client_name.into(), + pub_key.clone(), + None, + ) + .await?; + } + + let framed = self.accept_finalize(framed, acceptor).await?; + debug!("Shutting down {}", shutdown_label); + let (mut inner, _) = framed.into_inner(); + if let Err(e) = inner.shutdown().await { + debug!(?e, "{} shutdown error", shutdown_label); + } + + Ok(()) + } + pub async fn run(&mut self) -> Result<()> { // Create socket with control over options before binding. // Using TcpSocket instead of TcpListener::bind() allows setting From a5522467ab958c6fc25efa57d2cb4f5a87acb064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:19:29 +0900 Subject: [PATCH 310/325] feat(agent): add resize operation (#1401) --- crates/ironrdp-agent/src/cli.rs | 8 ++++++++ crates/ironrdp-agent/src/daemon.rs | 21 +++++++++++++++++++++ crates/ironrdp-agent/src/help.rs | 1 + crates/ironrdp-agent/src/ipc.rs | 19 +++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/crates/ironrdp-agent/src/cli.rs b/crates/ironrdp-agent/src/cli.rs index 7b89e352be..8994977079 100644 --- a/crates/ironrdp-agent/src/cli.rs +++ b/crates/ironrdp-agent/src/cli.rs @@ -84,6 +84,13 @@ enum Command { #[arg(long, action = clap::ArgAction::Set)] pressed: bool, }, + /// Resize the remote desktop. + Resize { + #[arg(long)] + width: u16, + #[arg(long)] + height: u16, + }, } #[derive(Args, Debug)] @@ -229,6 +236,7 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { Command::Wheel { delta, horizontal } => Request::Wheel { delta, horizontal }, Command::KeyScancode { scancode, pressed } => Request::KeyScancode { scancode, pressed }, Command::KeyUnicode { character, pressed } => Request::KeyUnicode { ch: character, pressed }, + Command::Resize { width, height } => Request::Resize { width, height }, }; let response = transport::send_request(&endpoint, &request).await?; diff --git a/crates/ironrdp-agent/src/daemon.rs b/crates/ironrdp-agent/src/daemon.rs index dc5b869310..bfb348f956 100644 --- a/crates/ironrdp-agent/src/daemon.rs +++ b/crates/ironrdp-agent/src/daemon.rs @@ -171,6 +171,7 @@ impl Daemon { } else { Operation::UnicodeKeyReleased(ch) }), + Request::Resize { width, height } => self.resize(width, height), } } @@ -400,6 +401,26 @@ impl Daemon { } } + fn resize(&self, width: u16, height: u16) -> Response { + if width == 0 || height == 0 { + return Response::error("width and height must be non-zero"); + } + let guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_ref() else { + return Response::error("no active session"); + }; + match session.input_tx.send(RdpInputEvent::Resize { + width, + height, + // No window/DPI concept in a headless agent: request the plain pixel size unscaled. + scale_factor: 100, + physical_size: None, + }) { + Ok(()) => Response::ok(), + Err(_) => Response::error("session input channel is closed"), + } + } + fn input(&self, operation: Operation) -> Response { let mut guard = self.state.lock().expect("daemon state poisoned"); let Some(session) = guard.as_mut() else { diff --git a/crates/ironrdp-agent/src/help.rs b/crates/ironrdp-agent/src/help.rs index 40301fd541..29de1ad8f6 100644 --- a/crates/ironrdp-agent/src/help.rs +++ b/crates/ironrdp-agent/src/help.rs @@ -69,6 +69,7 @@ Override with `--endpoint ` on any subcommand. - `wheel --delta N [--horizontal]` Rotate the wheel (negative N scrolls down/left). - `key-scancode --scancode <0x1D|29> --pressed ` - `key-unicode --char C --pressed ` Type by Unicode character. +- `resize --width W --height H` Resize the remote desktop. ## Errors diff --git a/crates/ironrdp-agent/src/ipc.rs b/crates/ironrdp-agent/src/ipc.rs index cf50dda1e6..1611643090 100644 --- a/crates/ironrdp-agent/src/ipc.rs +++ b/crates/ironrdp-agent/src/ipc.rs @@ -67,6 +67,8 @@ pub enum Request { KeyScancode { scancode: u16, pressed: bool }, /// Press or release a key identified by a Unicode character. KeyUnicode { ch: char, pressed: bool }, + /// Resize the remote desktop. + Resize { width: u16, height: u16 }, // TODO: add clipboard support (CLIPRDR), e.g. requests to read the remote clipboard text and to // set it, so an LLM can copy/paste to and from the session. } @@ -114,6 +116,11 @@ impl fmt::Debug for Request { .field("ch", ch) .field("pressed", pressed) .finish(), + Self::Resize { width, height } => f + .debug_struct("Resize") + .field("width", width) + .field("height", height) + .finish(), } } } @@ -676,6 +683,11 @@ impl Encode for Request { write_char(dst, *ch)?; write_bool(dst, *pressed)?; } + Self::Resize { width, height } => { + dst.write_u8(11); + dst.write_u16(*width); + dst.write_u16(*height); + } } Ok(()) } @@ -700,6 +712,7 @@ impl Encode for Request { Self::Wheel { .. } => 2 /* delta */ + 1 /* horizontal */, Self::KeyScancode { .. } => 2 /* scancode */ + 1 /* pressed */, Self::KeyUnicode { .. } => 4 /* ch */ + 1 /* pressed */, + Self::Resize { .. } => 2 /* width */ + 2 /* height */, } } } @@ -770,6 +783,12 @@ impl Decode<'_> for Request { let pressed = read_bool(src)?; Ok(Self::KeyUnicode { ch, pressed }) } + 11 => { + ensure_size!(in: src, size: 4); + let width = src.read_u16(); + let height = src.read_u16(); + Ok(Self::Resize { width, height }) + } _ => Err(ironrdp_core::invalid_field_err!("request", "unknown tag")), } } From 1cc7570ecba636812b3ff07e8f282bd37df12b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:19:49 +0900 Subject: [PATCH 311/325] feat(agent): add generic --prop KEY:TYPE:VALUE property overrides (#1402) --- crates/ironrdp-agent/README.md | 15 ++++++ crates/ironrdp-agent/src/cli.rs | 81 +++++++++++++++++++++++++++++--- crates/ironrdp-agent/src/help.rs | 18 ++++--- 3 files changed, 102 insertions(+), 12 deletions(-) diff --git a/crates/ironrdp-agent/README.md b/crates/ironrdp-agent/README.md index e217d0d9f1..ba376a9ea5 100644 --- a/crates/ironrdp-agent/README.md +++ b/crates/ironrdp-agent/README.md @@ -42,6 +42,21 @@ wins). When the overlay carries a secret (password/token), `Request::Status` rep `credentials_loaded`, so a caller should check the status first to learn whether it still needs to supply a password. +## Property overrides + +`connect` and `daemon-start` both accept a repeatable `--prop KEY:TYPE:VALUE` flag, using the same +grammar as one `.rdp` file line (`TYPE` is `i` for integer or `s` for string, e.g. +`--prop ironrdp_autologon:i:1 --prop username:s:admin`). It lets a caller set any property without a +dedicated CLI flag existing for it. Final precedence, low to high: + +``` +.rdp file → --prop overrides → named flags (--server/--username/…) → daemon's overlay +``` + +On `connect`, `--prop` overrides win over an optional `--rdp-file` but lose to the named flags. On +`daemon-start`, `--prop` overrides win over an optional `--overlay` file, and the resulting overlay +still wins over everything a `connect` request supplies (unchanged). + ## Logging Two logging concerns are kept separate: diff --git a/crates/ironrdp-agent/src/cli.rs b/crates/ironrdp-agent/src/cli.rs index 8994977079..0b8ecb1855 100644 --- a/crates/ironrdp-agent/src/cli.rs +++ b/crates/ironrdp-agent/src/cli.rs @@ -3,16 +3,22 @@ //! //! The CLI operates purely at the [`PropertySet`] level for connection config — it never calls //! typed `ConfigBuilder` setters. +//! +//! For `connect`, property precedence from low to high is: `.rdp` file → `--prop` overrides → +//! named flags (`--server`/`--username`/…). The daemon's own overlay (`daemon-start --overlay`, +//! itself built from a `.rdp` file with `--prop` overrides layered on top) wins over all of that — +//! see `Daemon::connect` in `daemon.rs`. #![allow(clippy::print_stdout, clippy::print_stderr)] use std::path::{Path, PathBuf}; +use std::str::FromStr; use anyhow::Context as _; use clap::{Args, CommandFactory as _, Parser, Subcommand, ValueEnum}; use ironrdp_cfg::{PropertySetExt as _, TargetAddr}; use ironrdp_input::MouseButton; -use ironrdp_propertyset::PropertySet; +use ironrdp_propertyset::{PropertySet, Value}; use crate::ipc::{KeyFilter, Payload, PropValue, Request, Response}; use crate::transport::{self, Endpoint}; @@ -101,6 +107,12 @@ struct DaemonArgs { /// `credentials loaded: true`. #[arg(long)] overlay: Option, + /// Arbitrary overlay property override (repeatable): `KEY:TYPE:VALUE`, the same grammar as one + /// `.rdp` file line (`TYPE` is `i` for integer or `s` for string), e.g. + /// `--prop ironrdp_autologon:i:1`. Applied on top of `--overlay`, so it lets an operator set any + /// property without a dedicated flag existing for it. + #[arg(long = "prop", value_name = "KEY:TYPE:VALUE")] + prop: Vec, } #[derive(Args, Debug)] @@ -108,6 +120,13 @@ struct ConnectArgs { /// Path to a .rdp file to read the base configuration from. #[arg(long)] rdp_file: Option, + /// Arbitrary property override (repeatable): `KEY:TYPE:VALUE`, the same grammar as one `.rdp` + /// file line (`TYPE` is `i` for integer or `s` for string), e.g. `--prop + /// ironrdp_autologon:i:1 --prop username:s:admin`. Applied on top of `--rdp-file` but under the + /// named flags below (e.g. `--username`), which always win for the same key. Use this to set + /// any property without a dedicated flag existing for it. + #[arg(long = "prop", value_name = "KEY:TYPE:VALUE")] + prop: Vec, /// RDP server address (host[:port]). Overrides the .rdp file. #[arg(long)] server: Option, @@ -174,6 +193,52 @@ impl CliMouseButton { } } +/// A single `--prop KEY:TYPE:VALUE` override, parsed with the same grammar as one `.rdp` file line +/// (see `ironrdp_rdpfile::load`): `TYPE` is `i` for integer or `s` for string. +#[derive(Clone, Debug)] +struct PropOverride { + key: String, + value: Value, +} + +impl FromStr for PropOverride { + type Err = String; + + fn from_str(input: &str) -> Result { + let mut parts = input.splitn(3, ':'); + let (Some(key), Some(ty), Some(value)) = (parts.next(), parts.next(), parts.next()) else { + return Err(format!("malformed --prop '{input}', expected KEY:TYPE:VALUE")); + }; + let key = key.trim(); + if key.is_empty() { + return Err(format!("empty key in --prop '{input}', expected KEY:TYPE:VALUE")); + } + let value = match ty { + "i" => value + .parse::() + .map(Value::from) + .map_err(|_| format!("invalid integer value in --prop '{input}'"))?, + "s" => Value::from(value), + other => { + return Err(format!( + "unknown type '{other}' in --prop '{input}', expected 'i' or 's'" + )); + } + }; + Ok(Self { + key: key.to_owned(), + value, + }) + } +} + +/// Applies `--prop` overrides onto `properties`, in argument order (last one for a given key wins). +fn apply_prop_overrides(properties: &mut PropertySet, overrides: Vec) { + for over in overrides { + properties.insert(over.key, over.value); + } +} + /// Parses an RDP scancode in decimal or `0x`-prefixed hexadecimal. fn parse_scancode(input: &str) -> Result { if let Some(hex) = input.strip_prefix("0x").or_else(|| input.strip_prefix("0X")) { @@ -200,7 +265,7 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { let request = match command { Command::DaemonStart(args) => { - let overlay = load_overlay(args.overlay.as_deref())?; + let overlay = load_overlay(args.overlay.as_deref(), args.prop)?; return crate::daemon::run(endpoint, overlay).await; } Command::Connect(args) => build_connect_request(args)?, @@ -243,9 +308,9 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { print_response(response) } -/// Loads an operator-provided overlay [`PropertySet`] from an optional `.rdp` file. Returns an -/// empty set when no path is given. -fn load_overlay(path: Option<&Path>) -> anyhow::Result { +/// Loads an operator-provided overlay [`PropertySet`] from an optional `.rdp` file, then layers +/// `--prop` overrides on top. Returns an empty set when neither is given. +fn load_overlay(path: Option<&Path>, prop_overrides: Vec) -> anyhow::Result { let mut properties = PropertySet::new(); if let Some(path) = path { let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; @@ -255,6 +320,7 @@ fn load_overlay(path: Option<&Path>) -> anyhow::Result { } } } + apply_prop_overrides(&mut properties, prop_overrides); Ok(properties) } @@ -273,7 +339,10 @@ fn build_connect_request(args: ConnectArgs) -> anyhow::Result { } } - // CLI overrides win. + // `--prop` overrides win over the .rdp file but lose to the named flags below. + apply_prop_overrides(&mut properties, args.prop); + + // Named CLI flags win over everything above. if let Some(server) = args.server { let address: TargetAddr = server .parse() diff --git a/crates/ironrdp-agent/src/help.rs b/crates/ironrdp-agent/src/help.rs index 29de1ad8f6..429abbd161 100644 --- a/crates/ironrdp-agent/src/help.rs +++ b/crates/ironrdp-agent/src/help.rs @@ -20,16 +20,22 @@ Override with `--endpoint ` on any subcommand. ## Lifecycle -- `daemon-start [--overlay FILE]` +- `daemon-start [--overlay FILE] [--prop KEY:TYPE:VALUE]...` Start the daemon (foreground). Run this first. `--overlay` preloads a .rdp file as an overlay applied to every `connect` (overlay wins), letting an operator provision any setting out of - band -- credentials in particular (e.g. the password). Check - `status` to see whether credentials are already loaded before - supplying any yourself. -- `connect [--rdp-file F] [--server H[:PORT]] [-u USER] [-p PASS] [-d DOMAIN] [--log-directive D]` + band -- credentials in particular (e.g. the password). `--prop` is + repeatable and layers additional overlay properties on top of + `--overlay`, using the same `KEY:TYPE:VALUE` grammar as one .rdp + file line (TYPE is `i` for integer or `s` for string), e.g. + `--prop ironrdp_autologon:i:1`. Check `status` to see whether + credentials are already loaded before supplying any yourself. +- `connect [--rdp-file F] [--prop KEY:TYPE:VALUE]... [--server H[:PORT]] [-u USER] [-p PASS] [-d DOMAIN] [--log-directive D]` Merge an optional .rdp file with CLI overrides into one config and - open a session. CLI flags win over the .rdp file. The config is + open a session. Precedence (low to high): .rdp file -> `--prop` + overrides -> named flags (`--server`/`-u`/`-p`/`-d`). `--prop` is + repeatable and lets you set any property without a dedicated flag + existing for it, e.g. `--prop username:s:admin`. The config is validated by the daemon, which replies with an error listing any missing or invalid fields. If `status` reports `credentials loaded: true`, omit `-p/--password` (and any other From 069786c96f5cee43bbbb23e3c50c42f62b9e20e6 Mon Sep 17 00:00:00 2001 From: uchouT Date: Thu, 2 Jul 2026 22:27:04 +0800 Subject: [PATCH 312/325] refactor(rdpeusb): seperate raw and validated `InternalIoControl` (#1403) Signed-off-by: uchouT --- crates/ironrdp-rdpeusb/src/client.rs | 2 +- crates/ironrdp-rdpeusb/src/io/mod.rs | 48 ++++++------ crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs | 76 ++++++------------- crates/ironrdp-rdpeusb/src/server.rs | 11 +-- 4 files changed, 52 insertions(+), 85 deletions(-) diff --git a/crates/ironrdp-rdpeusb/src/client.rs b/crates/ironrdp-rdpeusb/src/client.rs index 6a354f91ab..e04efe4794 100644 --- a/crates/ironrdp-rdpeusb/src/client.rs +++ b/crates/ironrdp-rdpeusb/src/client.rs @@ -616,7 +616,7 @@ impl DvcProcessor for UrbdrcDeviceClient { return Ok(Vec::new()); }; - let internal_io_ctl_packet = internal_io_ctl_pdu.into(); + let internal_io_ctl_packet = internal_io_ctl_pdu.try_into()?; if let Some(internal_io_ctl_response) = self.backend .internal_io_control(channel_id, request_id, internal_io_ctl_packet)? diff --git a/crates/ironrdp-rdpeusb/src/io/mod.rs b/crates/ironrdp-rdpeusb/src/io/mod.rs index 8194c99824..7d6b38a54e 100644 --- a/crates/ironrdp-rdpeusb/src/io/mod.rs +++ b/crates/ironrdp-rdpeusb/src/io/mod.rs @@ -70,8 +70,7 @@ pub struct IoControlCompletionResult { pub information: u32, /// Data produced by the request. /// - /// Its length must not exceed the request's [`IoControlPacket::output_buffer_size`] or - /// [`InternalIoControlPacket::output_buffer_size`]. For failures other than an + /// Its length must not exceed the request's output buffer size. For failures other than an /// insufficient-buffer result, this must be empty. pub output_buffer: Vec, } @@ -141,34 +140,39 @@ impl IoControlPacket { /// Backend-facing form of an RDPEUSB `INTERNAL_IO_CONTROL` request. #[derive(Debug, Clone)] -pub struct InternalIoControlPacket { - /// RDPEUSB-defined internal operation to perform. - pub ioctl_code: UsbInternalIoctlCode, - /// Raw input supplied to the operation. - pub input_buffer: Vec, - /// Maximum number of bytes that may be returned in the completion's output buffer. - pub output_buffer_size: u32, +pub enum InternalIoControlPacket { + QueryBusTime, } impl InternalIoControlPacket { pub(crate) fn into_pdu(self, msg_id: MessageId, req_id: RequestId, udev_iface: InterfaceId) -> InternalIoControl { - InternalIoControl { - msg_id, - udev_iface, - ioctl_code: self.ioctl_code, - input_buffer: self.input_buffer, - output_buffer_size: self.output_buffer_size, - req_id, + match self { + Self::QueryBusTime => InternalIoControl { + msg_id, + udev_iface, + ioctl_code: UsbInternalIoctlCode::QUERY_BUS_TIME, + input_buffer: Vec::new(), + output_buffer_size: 4, + req_id, + }, } } } -impl From for InternalIoControlPacket { - fn from(value: InternalIoControl) -> Self { - Self { - ioctl_code: value.ioctl_code, - input_buffer: value.input_buffer, - output_buffer_size: value.output_buffer_size, +impl TryFrom for InternalIoControlPacket { + type Error = PduError; + fn try_from(value: InternalIoControl) -> PduResult { + match value.ioctl_code { + UsbInternalIoctlCode::QUERY_BUS_TIME => { + if !value.input_buffer.is_empty() { + return Err(pdu_other_err!("internal io control input buffer must be empty")); + } + if value.output_buffer_size != 4 { + return Err(pdu_other_err!("internal io control output buffer size must be 4")); + } + Ok(Self::QueryBusTime) + } + _ => Err(pdu_other_err!("unsupported InternalIoControl ioctl code")), } } } diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs index b5d536e682..7b013b4924 100644 --- a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs @@ -228,9 +228,10 @@ impl IoControl { }; let input_buffer_size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; ensure_size!(in: src, - size: input_buffer_size /* InputBuffer */ + 4 /* OutputBufferSize */ + 4 /* RequestId */); + size: input_buffer_size); // TODO: size limit let input_buffer = src.read_slice(input_buffer_size).to_vec(); + ensure_size!(in: src, size: 4 /*output buffer size */ + 4 /* request id */); let output_buffer_size = src.read_u32(); let req_id = src.read_u32(); let io_control = Self { @@ -374,11 +375,11 @@ impl IoctlInternalUsb { /// [\[MS-RDPEUSB\] 2.2.13 USB Internal IO Control Code][1]. /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/55d1cd44-eda3-4cba-931c-c3cb8b3c3c92 -#[repr(u32)] -#[non_exhaustive] -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Copy)] #[doc(alias = "IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME")] -pub enum UsbInternalIoctlCode { +pub struct UsbInternalIoctlCode(pub u32); + +impl UsbInternalIoctlCode { /// [\[MS-RDPEUSB\] 2.2.13.1 IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME][1]. /// /// Sent when the server receives a request its system to query the device's current frame @@ -389,11 +390,9 @@ pub enum UsbInternalIoctlCode { /// /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/68506bc9-fedc-4fc1-b826-3cdbb1988774 #[doc(alias = "IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME")] - IoctlTsusbgdIoctlUsbdiQueryBusTime = 0x00224000, + pub const QUERY_BUS_TIME: Self = Self(0x00224000); } -const IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME: u32 = 0x00224000; - /// [\[MS-RDPEUSB\] 2.2.6.4 Internal IO Control Message (INTERNAL_IO_CONTROL)][1] message. /// /// Sent from the server to the client to submit an internal IO control request to the USB device. @@ -404,28 +403,18 @@ const IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME: u32 = 0x00224000; pub struct InternalIoControl { pub msg_id: MessageId, pub udev_iface: InterfaceId, - // Should make adding new codes easier. pub ioctl_code: UsbInternalIoctlCode, - /// As of **v20240423**, all codes used for this message require sending an empty input buffer. - /// - /// * [MS-RDPEUSB 2.2.13 USB Internal IO Control Code][1] - /// - /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/55d1cd44-eda3-4cba-931c-c3cb8b3c3c92 pub input_buffer: Vec, pub output_buffer_size: u32, pub req_id: RequestIdIoctl, } impl InternalIoControl { - #[expect(clippy::identity_op, reason = "for developer documentation purposes?")] - pub const PAYLOAD_SIZE: usize = 4 // IoControlCode + pub const PAYLOAD_MIN_SIZE: usize = 4 // IoControlCode + 4 // InputBufferSize - + 0 // InputBuffer + 4 // OutputBufferSize + 4; // RequestId - pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */ + Self::PAYLOAD_SIZE; - pub fn header(&self) -> SharedMsgHeader { SharedMsgHeader { iface_id: self.udev_iface.with_mask(Mask::Proxy), @@ -435,40 +424,23 @@ impl InternalIoControl { } pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { - ensure_size!(in: src, size: Self::PAYLOAD_SIZE); + ensure_size!(in: src, size: Self::PAYLOAD_MIN_SIZE); - { - let code = src.read_u32(); - if code != IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME { - return Err(unsupported_value_err!( - "INTERNAL_IO_CONTROL::IoControlCode", - format!("{code:#X}") - )); - } - } - { - let size = src.read_u32(/* InputBufferSize */); - if size != 0 { - return Err(unsupported_value_err!( - "INTERNAL_IO_CONTROL::InputBufferSize", - format!("{size:#X}") - )); - } - } + let code = src.read_u32(); + + let size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, size: size); + let input_buffer = src.read_slice(size).to_vec(); + + ensure_size!(in: src, size: 4 /*output buffer size */ + 4 /* request id */); let output_buffer_size = src.read_u32(/* OutputBufferSize */); - if output_buffer_size != 0x4 { - return Err(unsupported_value_err!( - "INTERNAL_IO_CONTROL::OutputBufferSize", - format!("{output_buffer_size:#X}") - )); - } let req_id = src.read_u32(); Ok(Self { msg_id, udev_iface, - ioctl_code: UsbInternalIoctlCode::IoctlTsusbgdIoctlUsbdiQueryBusTime, - input_buffer: Vec::new(), + ioctl_code: UsbInternalIoctlCode(code), + input_buffer, output_buffer_size, req_id, }) @@ -477,12 +449,12 @@ impl InternalIoControl { impl Encode for InternalIoControl { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - + ensure_size!(in: dst, size: self.size()); self.header().encode(dst)?; - dst.write_u32(IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME); // IoControlCode - dst.write_u32(0x0); // InputBufferSize - dst.write_u32(0x4); // OutputBufferSize + dst.write_u32(self.ioctl_code.0); // IoControlCode + dst.write_u32(self.input_buffer.len().try_into().map_err(|e| other_err!(source: e))?); // InputBufferSize + dst.write_slice(&self.input_buffer); // InputBuffer + dst.write_u32(self.output_buffer_size); // OutputBufferSize dst.write_u32(self.req_id); Ok(()) @@ -493,7 +465,7 @@ impl Encode for InternalIoControl { } fn size(&self) -> usize { - Self::FIXED_PART_SIZE + SharedMsgHeader::SIZE_REQ + Self::PAYLOAD_MIN_SIZE + self.input_buffer.len() } } diff --git a/crates/ironrdp-rdpeusb/src/server.rs b/crates/ironrdp-rdpeusb/src/server.rs index 9afcdb1a4e..99a75f03a1 100644 --- a/crates/ironrdp-rdpeusb/src/server.rs +++ b/crates/ironrdp-rdpeusb/src/server.rs @@ -307,20 +307,11 @@ impl UrbdrcDeviceServer { let udev_iface = self.usb_device_iface()?; let request_id = self.request_id_alloc.alloc(); - // Currently, INTERNAL_IO_CONTROL is specified with an empty input buffer and a fixed 4-byte output buffer. - if !internal_io_ctl_packet.input_buffer.is_empty() { - return Err(pdu_other_err!("internal io control input buffer must be empty")); - } - if internal_io_ctl_packet.output_buffer_size != 4 { - return Err(pdu_other_err!("internal io control output buffer size must be 4")); - } - - let output_buffer_size = 4; let request = internal_io_ctl_packet.into_pdu(self.msg_alloc.alloc(), request_id, udev_iface); self.insert_pending_io( request_id, Pending::InternalIoCtl { - max_output_buf_size: output_buffer_size, + max_output_buf_size: request.output_buffer_size, }, )?; From 32a8736d44ccea1cd4d88555c3631008dcfbfbf4 Mon Sep 17 00:00:00 2001 From: uchouT Date: Wed, 8 Jul 2026 00:56:17 +0800 Subject: [PATCH 313/325] refactor(rdpeusb): eliminate repetition (#1407) --- crates/ironrdp-rdpeusb/src/client.rs | 396 ++++++++++++--------------- 1 file changed, 181 insertions(+), 215 deletions(-) diff --git a/crates/ironrdp-rdpeusb/src/client.rs b/crates/ironrdp-rdpeusb/src/client.rs index e04efe4794..b5806a02af 100644 --- a/crates/ironrdp-rdpeusb/src/client.rs +++ b/crates/ironrdp-rdpeusb/src/client.rs @@ -311,9 +311,14 @@ impl UrbdrcDeviceClient { self.udev_iface } - fn completion_iface_and_entry( + fn accepts_io_request(&self, udev_iface: InterfaceId, request_id: RequestId) -> bool { + self.ready_for_io && udev_iface == self.udev_iface && !self.pending_io.contains_key(&request_id) + } + + fn pending_completion( &mut self, request_id: RequestId, + expected_kind: PendingKind, ) -> PduResult<( InterfaceId, alloc::collections::btree_map::OccupiedEntry<'_, u32, Pending>, @@ -324,6 +329,10 @@ impl UrbdrcDeviceClient { let Entry::Occupied(entry) = self.pending_io.entry(request_id) else { return Err(pdu_other_err!("completion mismatch")); }; + if entry.get().kind != expected_kind { + return Err(pdu_other_err!("completion mismatch")); + } + Ok((completion_iface, entry)) } @@ -332,27 +341,7 @@ impl UrbdrcDeviceClient { request_id: RequestId, response: IoControlCompletionResult, ) -> PduResult { - let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; - let (msg_id, max_output_buf_size) = match entry.get() { - Pending::IoCtl { - msg_id, - max_output_buf_size, - } => (*msg_id, *max_output_buf_size), - _ => return Err(pdu_other_err!("completion mismatch")), - }; - - let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), max_output_buf_size)?; - entry.remove(); - - Ok(Box::new(IoControlCompletion { - msg_id, - completion_iface, - hresult: response.hresult, - request_id, - information: response.information, - output_buffer_size, - output_buffer: response.output_buffer, - })) + self.complete_io_control(PendingKind::IoCtl, request_id, response) } pub fn internal_io_ctl_completion( @@ -360,27 +349,41 @@ impl UrbdrcDeviceClient { request_id: RequestId, response: IoControlCompletionResult, ) -> PduResult { - let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; - let (msg_id, max_output_buf_size) = match entry.get() { - Pending::InternalIoCtl { - msg_id, - max_output_buf_size, - } => (*msg_id, *max_output_buf_size), - _ => return Err(pdu_other_err!("completion mismatch")), - }; + self.complete_io_control(PendingKind::InternalIoCtl, request_id, response) + } - let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), max_output_buf_size)?; + fn complete_io_control( + &mut self, + expected_kind: PendingKind, + request_id: RequestId, + response: IoControlCompletionResult, + ) -> PduResult { + let (completion_iface, entry) = self.pending_completion(request_id, expected_kind)?; + let pending = *entry.get(); + let completion = build_io_control_completion(pending, completion_iface, request_id, response)?; entry.remove(); - Ok(Box::new(IoControlCompletion { - msg_id, - completion_iface, - hresult: response.hresult, - request_id, - information: response.information, - output_buffer_size, - output_buffer: response.output_buffer, - })) + Ok(completion) + } + + fn finish_io_control_request( + &mut self, + request_id: RequestId, + completion_iface: InterfaceId, + pending: Pending, + response: Option, + ) -> PduResult> { + if let Some(response) = response { + Ok(vec![build_io_control_completion( + pending, + completion_iface, + request_id, + response, + )?]) + } else { + self.pending_io.insert(request_id, pending); + Ok(Vec::new()) + } } pub fn transfer_in_completion( @@ -388,17 +391,8 @@ impl UrbdrcDeviceClient { request_id: RequestId, response: TransferInCompletionResult, ) -> PduResult { - let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; - let (msg_id, max_output_buf_size) = match entry.get() { - Pending::TransferIn { - msg_id, - max_output_buf_size, - } => (*msg_id, *max_output_buf_size), - _ => return Err(pdu_other_err!("completion mismatch")), - }; - - let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), max_output_buf_size)?; - entry.remove(); + let (completion_iface, entry) = self.pending_completion(request_id, PendingKind::TransferIn)?; + let pending = *entry.get(); #[expect( clippy::missing_panics_doc, @@ -406,26 +400,10 @@ impl UrbdrcDeviceClient { )] let req_id = RequestIdTransferInOut::try_from(request_id) .expect("pending TransferIn request id must be a TS_URB request id"); + let completion = build_transfer_in_completion(pending, completion_iface, req_id, response)?; + entry.remove(); - if response.output_buffer.is_empty() { - Ok(Box::new(UrbCompletionNoData { - msg_id, - completion_iface, - req_id, - ts_urb_result: response.ts_urb_result, - hresult: response.hresult, - output_buffer_size, - })) - } else { - Ok(Box::new(UrbCompletion { - msg_id, - completion_iface, - req_id, - ts_urb_result: response.ts_urb_result, - hresult: response.hresult, - output_buffer: response.output_buffer, - })) - } + Ok(completion) } pub fn transfer_out_completion( @@ -433,20 +411,8 @@ impl UrbdrcDeviceClient { request_id: RequestId, response: TransferOutCompletionResult, ) -> PduResult { - let (completion_iface, entry) = self.completion_iface_and_entry(request_id)?; - let (msg_id, max_output_buf_size) = match entry.get() { - Pending::TransferOut { - msg_id, - max_output_buf_size, - } => (*msg_id, *max_output_buf_size), - _ => return Err(pdu_other_err!("completion mismatch")), - }; - - if response.output_buffer_size > max_output_buf_size { - return Err(pdu_other_err!("output buffer exceeds maximum amount")); - } - - entry.remove(); + let (completion_iface, entry) = self.pending_completion(request_id, PendingKind::TransferOut)?; + let pending = *entry.get(); #[expect( clippy::missing_panics_doc, @@ -454,18 +420,81 @@ impl UrbdrcDeviceClient { )] let req_id = RequestIdTransferInOut::try_from(request_id) .expect("pending TransferOut request id must be a TS_URB request id"); + let completion = build_transfer_out_completion(pending, completion_iface, req_id, response)?; + entry.remove(); + + Ok(completion) + } +} + +fn build_io_control_completion( + pending: Pending, + completion_iface: InterfaceId, + request_id: RequestId, + response: IoControlCompletionResult, +) -> PduResult { + let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), pending.max_output_buf_size)?; + + Ok(Box::new(IoControlCompletion { + msg_id: pending.msg_id, + completion_iface, + hresult: response.hresult, + request_id, + information: response.information, + output_buffer_size, + output_buffer: response.output_buffer, + })) +} + +fn build_transfer_in_completion( + pending: Pending, + completion_iface: InterfaceId, + req_id: RequestIdTransferInOut, + response: TransferInCompletionResult, +) -> PduResult { + let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), pending.max_output_buf_size)?; + if response.output_buffer.is_empty() { Ok(Box::new(UrbCompletionNoData { - msg_id, + msg_id: pending.msg_id, completion_iface, req_id, ts_urb_result: response.ts_urb_result, hresult: response.hresult, - output_buffer_size: response.output_buffer_size, + output_buffer_size, + })) + } else { + Ok(Box::new(UrbCompletion { + msg_id: pending.msg_id, + completion_iface, + req_id, + ts_urb_result: response.ts_urb_result, + hresult: response.hresult, + output_buffer: response.output_buffer, })) } } +fn build_transfer_out_completion( + pending: Pending, + completion_iface: InterfaceId, + req_id: RequestIdTransferInOut, + response: TransferOutCompletionResult, +) -> PduResult { + if response.output_buffer_size > pending.max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + + Ok(Box::new(UrbCompletionNoData { + msg_id: pending.msg_id, + completion_iface, + req_id, + ts_urb_result: response.ts_urb_result, + hresult: response.hresult, + output_buffer_size: response.output_buffer_size, + })) +} + fn check_output_buffer_size(output_buffer_size: usize, max_output_buf_size: u32) -> PduResult { let output_buffer_size = u32::try_from(output_buffer_size).map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; @@ -565,13 +594,10 @@ impl DvcProcessor for UrbdrcDeviceClient { } } IoCtl(io_ctl_pdu) => { - if !self.ready_for_io || io_ctl_pdu.udev_iface != self.udev_iface { - return Ok(Vec::new()); - } let msg_id = io_ctl_pdu.msg_id; let request_id = io_ctl_pdu.req_id; let max_output_buf_size = io_ctl_pdu.output_buffer_size; - if self.pending_io.contains_key(&request_id) { + if !self.accepts_io_request(io_ctl_pdu.udev_iface, request_id) { return Ok(Vec::new()); } let Some(completion_iface) = self.request_completion else { @@ -579,37 +605,23 @@ impl DvcProcessor for UrbdrcDeviceClient { }; let io_ctl_packet = io_ctl_pdu.into(); - if let Some(io_ctl_response) = self.backend.io_control(channel_id, request_id, io_ctl_packet)? { - let output_buffer_size = - check_output_buffer_size(io_ctl_response.output_buffer.len(), max_output_buf_size)?; - Ok(vec![Box::new(IoControlCompletion { + let response = self.backend.io_control(channel_id, request_id, io_ctl_packet)?; + self.finish_io_control_request( + request_id, + completion_iface, + Pending { + kind: PendingKind::IoCtl, msg_id, - completion_iface, - hresult: io_ctl_response.hresult, - request_id, - information: io_ctl_response.information, - output_buffer_size, - output_buffer: io_ctl_response.output_buffer, - })]) - } else { - self.pending_io.insert( - request_id, - Pending::IoCtl { - msg_id, - max_output_buf_size, - }, - ); - Ok(Vec::new()) - } + max_output_buf_size, + }, + response, + ) } InternalIoCtl(internal_io_ctl_pdu) => { - if !self.ready_for_io || internal_io_ctl_pdu.udev_iface != self.udev_iface { - return Ok(Vec::new()); - } let msg_id = internal_io_ctl_pdu.msg_id; let request_id = internal_io_ctl_pdu.req_id; let max_output_buf_size = internal_io_ctl_pdu.output_buffer_size; - if self.pending_io.contains_key(&request_id) { + if !self.accepts_io_request(internal_io_ctl_pdu.udev_iface, request_id) { return Ok(Vec::new()); } let Some(completion_iface) = self.request_completion else { @@ -617,40 +629,25 @@ impl DvcProcessor for UrbdrcDeviceClient { }; let internal_io_ctl_packet = internal_io_ctl_pdu.try_into()?; - if let Some(internal_io_ctl_response) = - self.backend - .internal_io_control(channel_id, request_id, internal_io_ctl_packet)? - { - let output_buffer_size = - check_output_buffer_size(internal_io_ctl_response.output_buffer.len(), max_output_buf_size)?; - Ok(vec![Box::new(IoControlCompletion { + let response = self + .backend + .internal_io_control(channel_id, request_id, internal_io_ctl_packet)?; + self.finish_io_control_request( + request_id, + completion_iface, + Pending { + kind: PendingKind::InternalIoCtl, msg_id, - completion_iface, - hresult: internal_io_ctl_response.hresult, - request_id, - information: internal_io_ctl_response.information, - output_buffer_size, - output_buffer: internal_io_ctl_response.output_buffer, - })]) - } else { - self.pending_io.insert( - request_id, - Pending::InternalIoCtl { - msg_id, - max_output_buf_size, - }, - ); - Ok(Vec::new()) - } + max_output_buf_size, + }, + response, + ) } TransferIn(transfer_in_pdu) => { - if !self.ready_for_io || transfer_in_pdu.udev_iface != self.udev_iface { - return Ok(Vec::new()); - } let msg_id = transfer_in_pdu.msg_id; let max_output_buf_size = transfer_in_pdu.output_buffer_size; let request_id = transfer_in_pdu.request_id(); - if self.pending_io.contains_key(&request_id.into()) { + if !self.accepts_io_request(transfer_in_pdu.udev_iface, request_id.into()) { return Ok(Vec::new()); } let Some(completion_iface) = self.request_completion else { @@ -662,51 +659,32 @@ impl DvcProcessor for UrbdrcDeviceClient { output_buffer_size: transfer_in_pdu.output_buffer_size, }; - if let Some(urb_response) = self.backend.transfer_in(channel_id, request_id.into(), transfer_in)? { - let output_buffer_size = - check_output_buffer_size(urb_response.output_buffer.len(), max_output_buf_size)?; - if urb_response.output_buffer.is_empty() { - Ok(vec![Box::new(UrbCompletionNoData { - msg_id, - completion_iface, - req_id: request_id, - ts_urb_result: urb_response.ts_urb_result, - hresult: urb_response.hresult, - output_buffer_size, - })]) - } else { - Ok(vec![Box::new(UrbCompletion { - msg_id, - completion_iface, - req_id: request_id, - ts_urb_result: urb_response.ts_urb_result, - hresult: urb_response.hresult, - output_buffer: urb_response.output_buffer, - })]) - } + let pending = Pending { + kind: PendingKind::TransferIn, + msg_id, + max_output_buf_size, + }; + if let Some(response) = self.backend.transfer_in(channel_id, request_id.into(), transfer_in)? { + Ok(vec![build_transfer_in_completion( + pending, + completion_iface, + request_id, + response, + )?]) } else { - self.pending_io.insert( - request_id.into(), - Pending::TransferIn { - msg_id, - max_output_buf_size, - }, - ); + self.pending_io.insert(request_id.into(), pending); Ok(Vec::new()) } } TransferOut(transfer_out_pdu) => { - if !self.ready_for_io || transfer_out_pdu.udev_iface != self.udev_iface { - return Ok(Vec::new()); - } let msg_id = transfer_out_pdu.msg_id; - let output_buffer_size = u32::try_from(transfer_out_pdu.output_buffer.len()) - .map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; let request_id = transfer_out_pdu.ts_urb.header.req_id; let no_ack = transfer_out_pdu.ts_urb.header.no_ack; - if self.pending_io.contains_key(&request_id.into()) { + if !self.accepts_io_request(transfer_out_pdu.udev_iface, request_id.into()) { return Ok(Vec::new()); } + let output_buffer_size = u32::try_from(transfer_out_pdu.output_buffer.len()) + .map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; let transfer_out = TransferOutPacket { ts_urb: transfer_out_pdu.ts_urb.into(), @@ -720,28 +698,20 @@ impl DvcProcessor for UrbdrcDeviceClient { let Some(completion_iface) = self.request_completion else { return Ok(Vec::new()); }; - if let Some(urb_response) = - self.backend.transfer_out(channel_id, request_id.into(), transfer_out)? - { - if urb_response.output_buffer_size > output_buffer_size { - return Err(pdu_other_err!("output buffer exceeds maximum amount")); - } - Ok(vec![Box::new(UrbCompletionNoData { - msg_id, + let pending = Pending { + kind: PendingKind::TransferOut, + msg_id, + max_output_buf_size: output_buffer_size, + }; + if let Some(response) = self.backend.transfer_out(channel_id, request_id.into(), transfer_out)? { + Ok(vec![build_transfer_out_completion( + pending, completion_iface, - req_id: request_id, - ts_urb_result: urb_response.ts_urb_result, - hresult: urb_response.hresult, - output_buffer_size: urb_response.output_buffer_size, - })]) + request_id, + response, + )?]) } else { - self.pending_io.insert( - request_id.into(), - Pending::TransferOut { - msg_id, - max_output_buf_size: output_buffer_size, - }, - ); + self.pending_io.insert(request_id.into(), pending); Ok(Vec::new()) } } @@ -754,21 +724,17 @@ impl_as_any!(UrbdrcDeviceClient); impl DvcClientProcessor for UrbdrcDeviceClient {} -enum Pending { - IoCtl { - msg_id: MessageId, - max_output_buf_size: u32, - }, - InternalIoCtl { - msg_id: MessageId, - max_output_buf_size: u32, - }, - TransferIn { - msg_id: MessageId, - max_output_buf_size: u32, - }, - TransferOut { - msg_id: MessageId, - max_output_buf_size: u32, - }, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingKind { + IoCtl, + InternalIoCtl, + TransferIn, + TransferOut, +} + +#[derive(Debug, Clone, Copy)] +struct Pending { + kind: PendingKind, + msg_id: MessageId, + max_output_buf_size: u32, } From 5c22f86a7150bc10c26a3be39bfaebf84c67d781 Mon Sep 17 00:00:00 2001 From: Zac Bergquist Date: Wed, 8 Jul 2026 15:30:06 +0200 Subject: [PATCH 314/325] fix(session): reduce dependency on ironrdp-connector (#1419) Removes the leftover legacy modules and moves actually useful utilities to ironrdp-pdu crate. --- .../src/connection_activation.rs | 15 +- .../src/connection_finalization.rs | 28 ++- crates/ironrdp-connector/src/legacy.rs | 221 ------------------ crates/ironrdp-connector/src/lib.rs | 2 - .../ironrdp-connector/src/license_exchange.rs | 14 +- crates/ironrdp-pdu/src/mcs.rs | 71 +++++- crates/ironrdp-pdu/src/rdp/headers.rs | 155 +++++++++++- crates/ironrdp-session/src/fast_path.rs | 4 +- crates/ironrdp-session/src/legacy.rs | 5 - crates/ironrdp-session/src/lib.rs | 1 - crates/ironrdp-session/src/x224/mod.rs | 21 +- 11 files changed, 269 insertions(+), 268 deletions(-) delete mode 100644 crates/ironrdp-connector/src/legacy.rs delete mode 100644 crates/ironrdp-session/src/legacy.rs diff --git a/crates/ironrdp-connector/src/connection_activation.rs b/crates/ironrdp-connector/src/connection_activation.rs index fa053676e4..844e7457e8 100644 --- a/crates/ironrdp-connector/src/connection_activation.rs +++ b/crates/ironrdp-connector/src/connection_activation.rs @@ -5,8 +5,8 @@ use ironrdp_pdu::rdp::capability_sets::CapabilitySet; use tracing::{debug, warn}; use crate::{ - Config, ConnectionFinalizationSequence, ConnectorResult, DesktopSize, Sequence, State, Written, general_err, - legacy, reason_err, + Config, ConnectionFinalizationSequence, ConnectorError, ConnectorErrorExt as _, ConnectorResult, DesktopSize, + Sequence, State, Written, general_err, reason_err, }; /// Represents the Capability Exchange and Connection Finalization phases @@ -106,8 +106,10 @@ impl Sequence for ConnectionActivationSequence { } => { debug!("Capabilities Exchange"); - let send_data_indication_ctx = legacy::decode_send_data_indication(input)?; - let share_control_ctx = legacy::decode_share_control(send_data_indication_ctx)?; + let send_data_indication_ctx = + ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; + let share_control_ctx = + rdp::headers::decode_share_control(send_data_indication_ctx).map_err(ConnectorError::decode)?; debug!(message = ?share_control_ctx.pdu, "Received"); @@ -191,13 +193,14 @@ impl Sequence for ConnectionActivationSequence { debug!(message = ?client_confirm_active, "Send"); - let written = legacy::encode_share_control( + let written = rdp::headers::encode_share_control( user_channel_id, io_channel_id, share_id, client_confirm_active, output, - )?; + ) + .map_err(ConnectorError::encode)?; ( Written::from_size(written)?, diff --git a/crates/ironrdp-connector/src/connection_finalization.rs b/crates/ironrdp-connector/src/connection_finalization.rs index 7eccbcac31..1ad0e3357c 100644 --- a/crates/ironrdp-connector/src/connection_finalization.rs +++ b/crates/ironrdp-connector/src/connection_finalization.rs @@ -7,7 +7,9 @@ use ironrdp_pdu::rdp::headers::ShareDataPdu; use ironrdp_pdu::rdp::{finalization_messages, server_error_info}; use tracing::{debug, warn}; -use crate::{ConnectorResult, Sequence, State, Written, general_err, legacy, reason_err}; +use crate::{ + ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written, general_err, reason_err, +}; #[derive(Default, Debug, Copy, Clone)] #[non_exhaustive] @@ -98,13 +100,14 @@ impl Sequence for ConnectionFinalizationSequence { debug!(?message, "Send"); - let written = legacy::encode_share_data( + let written = ironrdp_pdu::rdp::headers::encode_share_data( self.user_channel_id, self.io_channel_id, self.share_id, message, output, - )?; + ) + .map_err(ConnectorError::encode)?; ( Written::from_size(written)?, @@ -121,13 +124,14 @@ impl Sequence for ConnectionFinalizationSequence { debug!(?message, "Send"); - let written = legacy::encode_share_data( + let written = ironrdp_pdu::rdp::headers::encode_share_data( self.user_channel_id, self.io_channel_id, self.share_id, message, output, - )?; + ) + .map_err(ConnectorError::encode)?; ( Written::from_size(written)?, @@ -144,13 +148,14 @@ impl Sequence for ConnectionFinalizationSequence { debug!(?message, "Send"); - let written = legacy::encode_share_data( + let written = ironrdp_pdu::rdp::headers::encode_share_data( self.user_channel_id, self.io_channel_id, self.share_id, message, output, - )?; + ) + .map_err(ConnectorError::encode)?; (Written::from_size(written)?, ConnectionFinalizationState::SendFontList) } @@ -160,13 +165,14 @@ impl Sequence for ConnectionFinalizationSequence { debug!(?message, "Send"); - let written = legacy::encode_share_data( + let written = ironrdp_pdu::rdp::headers::encode_share_data( self.user_channel_id, self.io_channel_id, self.share_id, message, output, - )?; + ) + .map_err(ConnectorError::encode)?; ( Written::from_size(written)?, @@ -175,8 +181,8 @@ impl Sequence for ConnectionFinalizationSequence { } ConnectionFinalizationState::WaitForResponse => { - let ctx = legacy::decode_send_data_indication(input)?; - let ctx = legacy::decode_share_data(ctx)?; + let ctx = ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; + let ctx = ironrdp_pdu::rdp::headers::decode_share_data(ctx).map_err(ConnectorError::decode)?; debug!(message = ?ctx.pdu, "Received"); diff --git a/crates/ironrdp-connector/src/legacy.rs b/crates/ironrdp-connector/src/legacy.rs deleted file mode 100644 index e5da73b1c8..0000000000 --- a/crates/ironrdp-connector/src/legacy.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::borrow::Cow; - -use ironrdp_core::{Decode, Encode, WriteBuf, decode, encode_vec}; -use ironrdp_pdu::rdp; -use ironrdp_pdu::rdp::headers::{BASIC_SECURITY_HEADER_SIZE, BasicSecurityHeaderFlags, ServerDeactivateAll}; -use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu; -use ironrdp_pdu::x224::X224; - -use crate::{ConnectorError, ConnectorErrorExt as _, ConnectorResult, reason_err}; - -pub fn encode_send_data_request( - initiator_id: u16, - channel_id: u16, - user_msg: &T, - buf: &mut WriteBuf, -) -> ConnectorResult -where - T: Encode, -{ - let user_data = encode_vec(user_msg).map_err(ConnectorError::encode)?; - - let pdu = ironrdp_pdu::mcs::SendDataRequest { - initiator_id, - channel_id, - user_data: Cow::Owned(user_data), - }; - - let written = ironrdp_core::encode_buf(&X224(pdu), buf).map_err(ConnectorError::encode)?; - - Ok(written) -} - -#[derive(Debug, Clone, Copy)] -pub struct SendDataIndicationCtx<'a> { - pub initiator_id: u16, - pub channel_id: u16, - pub user_data: &'a [u8], -} - -impl<'a> SendDataIndicationCtx<'a> { - pub fn decode_user_data<'de, T>(&self) -> ConnectorResult - where - T: Decode<'de>, - 'a: 'de, - { - let msg = decode::(self.user_data).map_err(ConnectorError::decode)?; - Ok(msg) - } -} - -pub fn decode_send_data_indication(src: &[u8]) -> ConnectorResult> { - use ironrdp_pdu::mcs::McsMessage; - - let mcs_msg = decode::>>(src).map_err(ConnectorError::decode)?; - - match mcs_msg.0 { - McsMessage::SendDataIndication(msg) => { - let Cow::Borrowed(user_data) = msg.user_data else { - unreachable!() - }; - - Ok(SendDataIndicationCtx { - initiator_id: msg.initiator_id, - channel_id: msg.channel_id, - user_data, - }) - } - McsMessage::DisconnectProviderUltimatum(msg) => Err(reason_err!( - "decode_send_data_indication", - "received disconnect provider ultimatum: {:?}", - msg.reason - )), - _ => Err(reason_err!( - "decode_send_data_indication", - "unexpected MCS message: {}", - ironrdp_core::name(&mcs_msg) - )), - } -} - -pub fn encode_share_control( - initiator_id: u16, - channel_id: u16, - share_id: u32, - pdu: rdp::headers::ShareControlPdu, - buf: &mut WriteBuf, -) -> ConnectorResult { - let pdu_source = initiator_id; - - let share_control_header = rdp::headers::ShareControlHeader { - share_control_pdu: pdu, - pdu_source, - share_id, - }; - - encode_send_data_request(initiator_id, channel_id, &share_control_header, buf) -} - -#[derive(Debug, Clone)] -pub struct ShareControlCtx { - pub initiator_id: u16, - pub channel_id: u16, - pub share_id: u32, - pub pdu_source: u16, - pub pdu: rdp::headers::ShareControlPdu, -} - -pub fn decode_share_control(ctx: SendDataIndicationCtx<'_>) -> ConnectorResult { - let user_msg = ctx.decode_user_data::()?; - - Ok(ShareControlCtx { - initiator_id: ctx.initiator_id, - channel_id: ctx.channel_id, - share_id: user_msg.share_id, - pdu_source: user_msg.pdu_source, - pdu: user_msg.share_control_pdu, - }) -} - -pub fn encode_share_data( - initiator_id: u16, - channel_id: u16, - share_id: u32, - pdu: rdp::headers::ShareDataPdu, - buf: &mut WriteBuf, -) -> ConnectorResult { - let share_data_header = rdp::headers::ShareDataHeader { - share_data_pdu: pdu, - stream_priority: rdp::headers::StreamPriority::Medium, - compression_flags: rdp::headers::CompressionFlags::empty(), - compression_type: rdp::client_info::CompressionType::K8, // ignored if CompressionFlags::empty() - }; - - let share_control_pdu = rdp::headers::ShareControlPdu::Data(share_data_header); - - encode_share_control(initiator_id, channel_id, share_id, share_control_pdu, buf) -} - -#[derive(Debug, Clone)] -pub struct ShareDataCtx { - pub initiator_id: u16, - pub channel_id: u16, - pub share_id: u32, - pub pdu_source: u16, - pub pdu: rdp::headers::ShareDataPdu, -} - -pub fn decode_share_data(ctx: SendDataIndicationCtx<'_>) -> ConnectorResult { - let ctx = decode_share_control(ctx)?; - - let rdp::headers::ShareControlPdu::Data(share_data_header) = ctx.pdu else { - return Err(reason_err!( - "decode_share_data", - "received unexpected Share Control PDU: got {} (expected Data PDU)", - ctx.pdu.as_short_name(), - )); - }; - - Ok(ShareDataCtx { - initiator_id: ctx.initiator_id, - channel_id: ctx.channel_id, - share_id: ctx.share_id, - pdu_source: ctx.pdu_source, - pdu: share_data_header.share_data_pdu, - }) -} - -pub enum IoChannelPdu { - Data(ShareDataCtx), - DeactivateAll(ServerDeactivateAll), - /// Server Initiate Multitransport Request PDU. - /// - /// Received when the server wants the client to establish a sideband UDP transport. - MultitransportRequest(MultitransportRequestPdu), -} - -pub fn decode_io_channel(ctx: SendDataIndicationCtx<'_>) -> ConnectorResult { - // Multitransport PDUs use BasicSecurityHeader (flags:u16, flagsHi:u16) instead - // of the ShareControlHeader (totalLength:u16, pduType:u16, ...) used by all - // other IO channel PDUs. We discriminate by checking flagsHi == 0 (ShareControl - // has pduType there, which is always non-zero) and requiring flags to be a valid - // BasicSecurityHeaderFlags combination. - if ctx.user_data.len() >= BASIC_SECURITY_HEADER_SIZE { - let flags_raw = u16::from_le_bytes([ctx.user_data[0], ctx.user_data[1]]); - let flags_hi = u16::from_le_bytes([ctx.user_data[2], ctx.user_data[3]]); - - if flags_hi == 0 { - if let Some(flags) = BasicSecurityHeaderFlags::from_bits(flags_raw) { - if flags.contains(BasicSecurityHeaderFlags::TRANSPORT_REQ) { - if let Ok(pdu) = decode::(ctx.user_data) { - return Ok(IoChannelPdu::MultitransportRequest(pdu)); - } - } - } - } - } - - let ctx = decode_share_control(ctx)?; - - match ctx.pdu { - rdp::headers::ShareControlPdu::ServerDeactivateAll(deactivate_all) => { - Ok(IoChannelPdu::DeactivateAll(deactivate_all)) - } - rdp::headers::ShareControlPdu::Data(share_data_header) => { - let share_data_ctx = ShareDataCtx { - initiator_id: ctx.initiator_id, - channel_id: ctx.channel_id, - share_id: ctx.share_id, - pdu_source: ctx.pdu_source, - pdu: share_data_header.share_data_pdu, - }; - - Ok(IoChannelPdu::Data(share_data_ctx)) - } - other => Err(reason_err!( - "decode_io_channel", - "received unexpected Share Control PDU: got {} (expected Data PDU or Server Deactivate All PDU)", - other.as_short_name(), - )), - } -} diff --git a/crates/ironrdp-connector/src/lib.rs b/crates/ironrdp-connector/src/lib.rs index f78420078e..477f080e6c 100644 --- a/crates/ironrdp-connector/src/lib.rs +++ b/crates/ironrdp-connector/src/lib.rs @@ -3,8 +3,6 @@ mod macros; -pub mod legacy; - mod channel_connection; mod connection; pub mod connection_activation; diff --git a/crates/ironrdp-connector/src/license_exchange.rs b/crates/ironrdp-connector/src/license_exchange.rs index c226cc088e..8b77ec76a0 100644 --- a/crates/ironrdp-connector/src/license_exchange.rs +++ b/crates/ironrdp-connector/src/license_exchange.rs @@ -10,7 +10,7 @@ use ironrdp_pdu::rdp::server_license::{self, LicenseInformation, LicensePdu, Ser use rand::RngCore as _; use tracing::{debug, error, info, trace}; -use super::{ConnectorError, ConnectorErrorExt as _, custom_err, general_err, legacy}; +use super::{ConnectorError, ConnectorErrorExt as _, custom_err, general_err}; use crate::{ConnectorResult, ConnectorResultExt as _, Sequence, State, Written, encode_send_data_request}; #[derive(Default, Debug)] @@ -126,9 +126,11 @@ impl Sequence for LicenseExchangeSequence { } LicenseExchangeState::NewLicenseRequest => { - let send_data_indication_ctx = legacy::decode_send_data_indication(input)?; + let send_data_indication_ctx = + ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; let license_pdu = send_data_indication_ctx .decode_user_data::() + .map_err(ConnectorError::decode) .with_context("decode during LicenseExchangeState::NewLicenseRequest")?; match license_pdu { @@ -258,10 +260,12 @@ impl Sequence for LicenseExchangeSequence { } LicenseExchangeState::PlatformChallenge { encryption_data } => { - let send_data_indication_ctx = legacy::decode_send_data_indication(input)?; + let send_data_indication_ctx = + ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; let license_pdu = send_data_indication_ctx .decode_user_data::() + .map_err(ConnectorError::decode) .with_context("decode during LicenseExchangeState::PlatformChallenge")?; match license_pdu { @@ -310,10 +314,12 @@ impl Sequence for LicenseExchangeSequence { } LicenseExchangeState::UpgradeLicense { encryption_data } => { - let send_data_indication_ctx = legacy::decode_send_data_indication(input)?; + let send_data_indication_ctx = + ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; let license_pdu = send_data_indication_ctx .decode_user_data::() + .map_err(ConnectorError::decode) .with_context("decode during SERVER_NEW_LICENSE/LicenseExchangeState::UpgradeLicense")?; match license_pdu { diff --git a/crates/ironrdp-pdu/src/mcs.rs b/crates/ironrdp-pdu/src/mcs.rs index 28dd850531..679567cd2a 100644 --- a/crates/ironrdp-pdu/src/mcs.rs +++ b/crates/ironrdp-pdu/src/mcs.rs @@ -1,16 +1,81 @@ use std::borrow::Cow; use ironrdp_core::{ - IntoOwned, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, - read_padding, unexpected_message_type_err, + Decode, Encode, IntoOwned, ReadCursor, WriteBuf, WriteCursor, cast_length, decode, encode_buf, encode_vec, + ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, read_padding, unexpected_message_type_err, }; use crate::gcc::{ChannelDef, ClientGccBlocks, ConferenceCreateRequest, ConferenceCreateResponse}; use crate::tpdu::{TpduCode, TpduHeader}; use crate::tpkt::TpktHeader; -use crate::x224::{X224Pdu, user_data_size}; +use crate::x224::{X224, X224Pdu, user_data_size}; use crate::{DecodeResult, EncodeResult, impl_x224_pdu_borrowing, impl_x224_pdu_pod, per}; +/// Encodes an arbitrary PDU as the user data of an MCS [`SendDataRequest`], wrapped in an X.224 data PDU. +pub fn encode_send_data_request( + initiator_id: u16, + channel_id: u16, + user_msg: &T, + buf: &mut WriteBuf, +) -> EncodeResult +where + T: Encode, +{ + let user_data = encode_vec(user_msg)?; + + let pdu = SendDataRequest { + initiator_id, + channel_id, + user_data: Cow::Owned(user_data), + }; + + let written = encode_buf(&X224(pdu), buf)?; + + Ok(written) +} + +/// The user data carried by an MCS Send Data Indication, along with its channel routing information. +#[derive(Debug, Clone, Copy)] +pub struct SendDataIndicationCtx<'a> { + pub initiator_id: u16, + pub channel_id: u16, + pub user_data: &'a [u8], +} + +impl<'a> SendDataIndicationCtx<'a> { + pub fn decode_user_data<'de, T>(&self) -> DecodeResult + where + T: Decode<'de>, + 'a: 'de, + { + decode::(self.user_data) + } +} + +/// Decodes an X.224-wrapped MCS Send Data Indication and returns its [`SendDataIndicationCtx`]. +pub fn decode_send_data_indication(src: &[u8]) -> DecodeResult> { + let mcs_msg = decode::>>(src)?; + + match mcs_msg.0 { + McsMessage::SendDataIndication(msg) => { + let Cow::Borrowed(user_data) = msg.user_data else { + unreachable!() + }; + + Ok(SendDataIndicationCtx { + initiator_id: msg.initiator_id, + channel_id: msg.channel_id, + user_data, + }) + } + McsMessage::DisconnectProviderUltimatum(_) => Err(other_err!( + "decode_send_data_indication", + "received disconnect provider ultimatum" + )), + _ => Err(other_err!("decode_send_data_indication", "unexpected MCS message")), + } +} + // T.125 MCS is defined in: // // http://www.itu.int/rec/T-REC-T.125-199802-I/ diff --git a/crates/ironrdp-pdu/src/rdp/headers.rs b/crates/ironrdp-pdu/src/rdp/headers.rs index 2e4557d05d..31bf39f30e 100644 --- a/crates/ironrdp-pdu/src/rdp/headers.rs +++ b/crates/ironrdp-pdu/src/rdp/headers.rs @@ -1,17 +1,20 @@ use bitflags::bitflags; use ironrdp_core::{ - Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, - ensure_size, invalid_field_err, not_enough_bytes_err, other_err, read_padding, write_padding, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteBuf, WriteCursor, cast_length, decode, + ensure_fixed_part_size, ensure_size, invalid_field_err, not_enough_bytes_err, other_err, read_padding, + write_padding, }; use num_derive::FromPrimitive; use num_traits::FromPrimitive as _; use crate::codecs::rfx::FrameAcknowledgePdu; use crate::input::InputEventPdu; +use crate::mcs::SendDataIndicationCtx; use crate::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; use crate::rdp::capability_sets::{ClientConfirmActive, ServerDemandActive}; use crate::rdp::client_info; use crate::rdp::finalization_messages::{ControlPdu, FontPdu, MonitorLayoutPdu, SynchronizePdu}; +use crate::rdp::multitransport::MultitransportRequestPdu; use crate::rdp::refresh_rectangle::RefreshRectanglePdu; use crate::rdp::server_error_info::ServerSetErrorInfoPdu; use crate::rdp::session_info::SaveSessionInfoPdu; @@ -74,6 +77,154 @@ impl<'de> Decode<'de> for BasicSecurityHeader { } } +/// Encodes a [`ShareControlPdu`] wrapped in an MCS Send Data Request. +pub fn encode_share_control( + initiator_id: u16, + channel_id: u16, + share_id: u32, + pdu: ShareControlPdu, + buf: &mut WriteBuf, +) -> EncodeResult { + let share_control_header = ShareControlHeader { + share_control_pdu: pdu, + pdu_source: initiator_id, + share_id, + }; + + crate::mcs::encode_send_data_request(initiator_id, channel_id, &share_control_header, buf) +} + +/// Encodes a [`ShareDataPdu`] wrapped in a Share Control header and an MCS Send Data Request. +pub fn encode_share_data( + initiator_id: u16, + channel_id: u16, + share_id: u32, + pdu: ShareDataPdu, + buf: &mut WriteBuf, +) -> EncodeResult { + let share_data_header = ShareDataHeader { + share_data_pdu: pdu, + stream_priority: StreamPriority::Medium, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, // ignored if CompressionFlags::empty() + }; + + encode_share_control( + initiator_id, + channel_id, + share_id, + ShareControlPdu::Data(share_data_header), + buf, + ) +} + +/// A decoded Share Control PDU together with its channel routing information. +#[derive(Debug, Clone)] +pub struct ShareControlCtx { + pub initiator_id: u16, + pub channel_id: u16, + pub share_id: u32, + pub pdu_source: u16, + pub pdu: ShareControlPdu, +} + +/// Decodes a [`ShareControlHeader`] from the user data of a Send Data Indication. +pub fn decode_share_control(ctx: SendDataIndicationCtx<'_>) -> DecodeResult { + let user_msg = ctx.decode_user_data::()?; + + Ok(ShareControlCtx { + initiator_id: ctx.initiator_id, + channel_id: ctx.channel_id, + share_id: user_msg.share_id, + pdu_source: user_msg.pdu_source, + pdu: user_msg.share_control_pdu, + }) +} + +/// A decoded Share Data PDU together with its channel routing information. +#[derive(Debug, Clone)] +pub struct ShareDataCtx { + pub initiator_id: u16, + pub channel_id: u16, + pub share_id: u32, + pub pdu_source: u16, + pub pdu: ShareDataPdu, +} + +/// Decodes a [`ShareDataHeader`] from the user data of a Send Data Indication. +pub fn decode_share_data(ctx: SendDataIndicationCtx<'_>) -> DecodeResult { + let ctx = decode_share_control(ctx)?; + + let ShareControlPdu::Data(share_data_header) = ctx.pdu else { + return Err(other_err!( + "decode_share_data", + "received unexpected Share Control PDU (expected Data PDU)" + )); + }; + + Ok(ShareDataCtx { + initiator_id: ctx.initiator_id, + channel_id: ctx.channel_id, + share_id: ctx.share_id, + pdu_source: ctx.pdu_source, + pdu: share_data_header.share_data_pdu, + }) +} + +/// A PDU received on the RDP IO channel. +pub enum IoChannelPdu { + Data(ShareDataCtx), + DeactivateAll(ServerDeactivateAll), + /// Server Initiate Multitransport Request PDU. + /// + /// Received when the server wants the client to establish a sideband UDP transport. + MultitransportRequest(MultitransportRequestPdu), +} + +/// Decodes a PDU received on the RDP IO channel from the user data of a Send Data Indication. +pub fn decode_io_channel(ctx: SendDataIndicationCtx<'_>) -> DecodeResult { + // Multitransport PDUs use BasicSecurityHeader (flags:u16, flagsHi:u16) instead + // of the ShareControlHeader (totalLength:u16, pduType:u16, ...) used by all + // other IO channel PDUs. We discriminate by checking flagsHi == 0 (ShareControl + // has pduType there, which is always non-zero) and requiring flags to be a valid + // BasicSecurityHeaderFlags combination. + if ctx.user_data.len() >= BASIC_SECURITY_HEADER_SIZE { + let flags_raw = u16::from_le_bytes([ctx.user_data[0], ctx.user_data[1]]); + let flags_hi = u16::from_le_bytes([ctx.user_data[2], ctx.user_data[3]]); + + if flags_hi == 0 { + if let Some(flags) = BasicSecurityHeaderFlags::from_bits(flags_raw) { + if flags.contains(BasicSecurityHeaderFlags::TRANSPORT_REQ) { + if let Ok(pdu) = decode::(ctx.user_data) { + return Ok(IoChannelPdu::MultitransportRequest(pdu)); + } + } + } + } + } + + let ctx = decode_share_control(ctx)?; + + match ctx.pdu { + ShareControlPdu::ServerDeactivateAll(deactivate_all) => Ok(IoChannelPdu::DeactivateAll(deactivate_all)), + ShareControlPdu::Data(share_data_header) => { + let share_data_ctx = ShareDataCtx { + initiator_id: ctx.initiator_id, + channel_id: ctx.channel_id, + share_id: ctx.share_id, + pdu_source: ctx.pdu_source, + pdu: share_data_header.share_data_pdu, + }; + + Ok(IoChannelPdu::Data(share_data_ctx)) + } + _ => Err(other_err!( + "decode_io_channel", + "received unexpected Share Control PDU (expected Data PDU or Server Deactivate All PDU)" + )), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ShareControlHeader { diff --git a/crates/ironrdp-session/src/fast_path.rs b/crates/ironrdp-session/src/fast_path.rs index 2a05d1a230..9c48c7b944 100644 --- a/crates/ironrdp-session/src/fast_path.rs +++ b/crates/ironrdp-session/src/fast_path.rs @@ -695,7 +695,7 @@ impl FrameMarkerProcessor { match marker.frame_action { FrameAction::Begin => Ok(()), FrameAction::End => { - ironrdp_connector::legacy::encode_share_data( + ironrdp_pdu::rdp::headers::encode_share_data( self.user_channel_id, self.io_channel_id, self.share_id, @@ -704,7 +704,7 @@ impl FrameMarkerProcessor { }), output, ) - .map_err(crate::legacy::map_error)?; + .map_err(SessionError::encode)?; Ok(()) } diff --git a/crates/ironrdp-session/src/legacy.rs b/crates/ironrdp-session/src/legacy.rs deleted file mode 100644 index 1c755ce75b..0000000000 --- a/crates/ironrdp-session/src/legacy.rs +++ /dev/null @@ -1,5 +0,0 @@ -use crate::{SessionError, SessionErrorExt as _}; - -pub(crate) fn map_error(error: ironrdp_connector::ConnectorError) -> SessionError { - SessionError::custom("connector error", error) -} diff --git a/crates/ironrdp-session/src/lib.rs b/crates/ironrdp-session/src/lib.rs index 4c944ebd38..cf9862a968 100644 --- a/crates/ironrdp-session/src/lib.rs +++ b/crates/ironrdp-session/src/lib.rs @@ -6,7 +6,6 @@ mod macros; pub mod fast_path; pub mod image; -pub mod legacy; pub mod pointer; pub mod rfx; // FIXME: maybe this module should not be in this crate pub mod x224; diff --git a/crates/ironrdp-session/src/x224/mod.rs b/crates/ironrdp-session/src/x224/mod.rs index 079128e1be..74f471cff6 100644 --- a/crates/ironrdp-session/src/x224/mod.rs +++ b/crates/ironrdp-session/src/x224/mod.rs @@ -1,8 +1,7 @@ use ironrdp_connector::connection_activation::ConnectionActivationSequence; -use ironrdp_connector::legacy::SendDataIndicationCtx; use ironrdp_core::WriteBuf; use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel}; -use ironrdp_pdu::mcs::{DisconnectProviderUltimatum, DisconnectReason, McsMessage}; +use ironrdp_pdu::mcs::{DisconnectProviderUltimatum, DisconnectReason, McsMessage, SendDataIndicationCtx}; use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; use ironrdp_pdu::rdp::headers::ShareDataPdu; use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu; @@ -129,7 +128,7 @@ impl Processor { /// in the returned order. pub fn process(&mut self, frame: &[u8]) -> SessionResult> { let data_ctx: SendDataIndicationCtx<'_> = - ironrdp_connector::legacy::decode_send_data_indication(frame).map_err(crate::legacy::map_error)?; + ironrdp_pdu::mcs::decode_send_data_indication(frame).map_err(SessionError::decode)?; let channel_id = data_ctx.channel_id; if channel_id == self.io_channel_id { @@ -146,10 +145,10 @@ impl Processor { fn process_io_channel(&self, data_ctx: SendDataIndicationCtx<'_>) -> SessionResult> { debug_assert_eq!(data_ctx.channel_id, self.io_channel_id); - let io_channel = ironrdp_connector::legacy::decode_io_channel(data_ctx).map_err(crate::legacy::map_error)?; + let io_channel = ironrdp_pdu::rdp::headers::decode_io_channel(data_ctx).map_err(SessionError::decode)?; match io_channel { - ironrdp_connector::legacy::IoChannelPdu::Data(ctx) => { + ironrdp_pdu::rdp::headers::IoChannelPdu::Data(ctx) => { match ctx.pdu { ShareDataPdu::SaveSessionInfo(session_info) => { debug!("Got Session Save Info PDU: {session_info:?}"); @@ -198,14 +197,14 @@ impl Processor { ShareDataPdu::AutoDetectReq(AutoDetectRequest::RttRequest { sequence_number, .. }) => { let response = AutoDetectResponse::RttResponse { sequence_number }; let mut frame = WriteBuf::new(); - ironrdp_connector::legacy::encode_share_data( + ironrdp_pdu::rdp::headers::encode_share_data( self.user_channel_id, self.io_channel_id, self.share_id, ShareDataPdu::AutoDetectRsp(response), &mut frame, ) - .map_err(crate::legacy::map_error)?; + .map_err(SessionError::encode)?; debug!(sequence_number, "Responded to auto-detect RTT request"); Ok(vec![ProcessorOutput::ResponseFrame(frame.into_inner())]) } @@ -239,14 +238,14 @@ impl Processor { )), } } - ironrdp_connector::legacy::IoChannelPdu::MultitransportRequest(pdu) => { + ironrdp_pdu::rdp::headers::IoChannelPdu::MultitransportRequest(pdu) => { debug!( "Received Initiate Multitransport Request: request_id={}", pdu.request_id ); Ok(vec![ProcessorOutput::MultitransportRequest(pdu)]) } - ironrdp_connector::legacy::IoChannelPdu::DeactivateAll(_) => Ok(vec![ProcessorOutput::DeactivateAll( + ironrdp_pdu::rdp::headers::IoChannelPdu::DeactivateAll(_) => Ok(vec![ProcessorOutput::DeactivateAll( Box::new(self.connection_activation.reset_clone()), )]), } @@ -254,14 +253,14 @@ impl Processor { /// Send a pdu on the static global channel. Typically used to send input events pub fn encode_static(&self, output: &mut WriteBuf, pdu: ShareDataPdu) -> SessionResult { - let written = ironrdp_connector::legacy::encode_share_data( + let written = ironrdp_pdu::rdp::headers::encode_share_data( self.user_channel_id, self.io_channel_id, self.share_id, pdu, output, ) - .map_err(crate::legacy::map_error)?; + .map_err(SessionError::encode)?; Ok(written) } } From 9b4d01b4038ede1cdd329fd9ea47a5d241480d1d Mon Sep 17 00:00:00 2001 From: Anton Mostovoy Date: Wed, 8 Jul 2026 18:45:18 -0500 Subject: [PATCH 315/325] fix(pdu): decode MousePdu wheel rotation as two's complement, matching encode (#1415) --- crates/ironrdp-pdu/src/input/mouse.rs | 66 ++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/ironrdp-pdu/src/input/mouse.rs b/crates/ironrdp-pdu/src/input/mouse.rs index ac5e9936a3..0bbbd065dc 100644 --- a/crates/ironrdp-pdu/src/input/mouse.rs +++ b/crates/ironrdp-pdu/src/input/mouse.rs @@ -26,6 +26,14 @@ impl Encode for MousePdu { PointerFlags::empty().bits() }; + // The wire field is 9-bit two's complement: representable range is + // [-256, 255], narrower than i16. + debug_assert!( + (-256..=255).contains(&self.number_of_wheel_rotation_units), + "number_of_wheel_rotation_units out of the 9-bit two's-complement range [-256, 255]: {}", + self.number_of_wheel_rotation_units + ); + #[expect( clippy::as_conversions, clippy::cast_sign_loss, @@ -68,8 +76,15 @@ impl<'de> Decode<'de> for MousePdu { )] let wheel_rotations_bits = flags_raw as u8; + // Per MS-RDPBCGR 2.2.8.1.1.3.1.1.3, WheelRotationMask (0x01FF) is a 9-bit + // TWO'S-COMPLEMENT field: WHEEL_NEGATIVE (0x0100) is the sign bit of that + // 9-bit value, not an independent "negate this magnitude" flag. So a byte + // of 0xFF with WHEEL_NEGATIVE set means -1, not -255. This must mirror + // `encode` above, which already produces a proper two's-complement byte + // via a truncating cast (`self.number_of_wheel_rotation_units as u8`) — + // without this, `decode(encode(x))` does not round-trip for x < 0. let number_of_wheel_rotation_units = if flags.contains(PointerFlags::WHEEL_NEGATIVE) { - -i16::from(wheel_rotations_bits) + i16::from(wheel_rotations_bits) - 0x100 } else { i16::from(wheel_rotations_bits) }; @@ -101,3 +116,52 @@ bitflags! { const _ = !0; } } + +#[cfg(test)] +mod tests { + use ironrdp_core::{decode, encode_vec}; + + use super::*; + + fn mouse_pdu(number_of_wheel_rotation_units: i16) -> MousePdu { + MousePdu { + flags: PointerFlags::VERTICAL_WHEEL, + number_of_wheel_rotation_units, + x_position: 0, + y_position: 0, + } + } + + #[test] + fn wheel_rotation_units_round_trip_through_encode_decode() { + // Every representable value must survive an encode/decode round trip. + // This previously failed for small negative values: encode(-1) produced + // byte 0xFF + WHEEL_NEGATIVE, which decode incorrectly read back as -255 + // (sign-magnitude) instead of -1 (two's complement, matching encode). + // + // The wire field is 9-bit two's complement, so its representable domain + // is [-256, 255] (wider than i8, narrower than i16) — iterate that exact + // range rather than i8::MIN..=i8::MAX so this test documents (and checks) + // the real contract, not an arbitrary subset of it. + for value in -256i16..=255i16 { + let pdu = mouse_pdu(value); + let buffer = encode_vec(&pdu).unwrap(); + let decoded: MousePdu = decode(buffer.as_slice()).unwrap(); + assert_eq!( + decoded.number_of_wheel_rotation_units, value, + "round trip failed for {value}" + ); + } + } + + #[test] + fn small_negative_wheel_rotation_decodes_correctly() { + // WHEEL_NEGATIVE set, byte = 0xFF -> true value is -1 (two's complement: + // byte - 0x100), NOT -255 (sign-magnitude: -byte). + let flags = (PointerFlags::VERTICAL_WHEEL | PointerFlags::WHEEL_NEGATIVE).bits() | 0x00FF; + let mut buffer = [0u8; 6]; + buffer[0..2].copy_from_slice(&flags.to_le_bytes()); + let pdu: MousePdu = decode(buffer.as_slice()).unwrap(); + assert_eq!(pdu.number_of_wheel_rotation_units, -1); + } +} From c6a0286dcb49d9ac54c65c4f9325b41e05d541b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:42:15 +0900 Subject: [PATCH 316/325] fix(session)!: remove ironrdp-connector dependency (#1435) Removes the last ironrdp-connector coupling from ironrdp-session by turning Deactivate-All handling into a bare signal and shifting ownership of the Deactivation-Reactivation activation sequence back to each consumer. It introduces a ConnectionActivationFactory (fresh sequence per reactivation) and an ActiveStageBuilder so session construction no longer depends on ConnectionResult. --- Cargo.lock | 1 - crates/ironrdp-client/src/rdp.rs | 28 ++- crates/ironrdp-connector/src/connection.rs | 26 ++- .../src/connection_activation.rs | 130 ++++++------ crates/ironrdp-session/Cargo.toml | 1 - crates/ironrdp-session/src/active_stage.rs | 68 ++++--- crates/ironrdp-session/src/lib.rs | 2 +- crates/ironrdp-session/src/x224/mod.rs | 17 +- .../tests/session/autodetect.rs | 49 +---- .../tests/session/connection_activation.rs | 2 +- crates/ironrdp-testsuite-extra/tests/e2e.rs | 187 ++++++++++-------- crates/ironrdp-web/src/session.rs | 30 ++- crates/ironrdp/examples/screenshot.rs | 13 +- .../MainWindow.axaml.cs | 6 +- .../Generated/ActiveStage.cs | 24 +++ .../Generated/ActiveStageOutput.cs | 40 ++-- .../Generated/ActiveStageOutputType.cs | 6 + .../Generated/ClipboardMessageType.cs | 11 +- .../Generated/ConnectionActivationSequence.cs | 42 ++++ .../Generated/ConnectionActivationState.cs | 30 --- ...tionActivationStateCapabilitiesExchange.cs | 105 ---------- ...onActivationStateConnectionFinalization.cs | 42 ---- .../ConnectionActivationStateFinalized.cs | 42 ---- .../Generated/NetworkCharacteristics.cs | 137 +++++++++++++ .../Generated/RawActiveStage.cs | 11 ++ .../Generated/RawActiveStageOutput.cs | 12 +- .../Generated/RawActiveStageOutputType.cs | 6 + .../Generated/RawClipboardMessageType.cs | 11 +- .../RawConnectionActivationSequence.cs | 6 + .../Generated/RawConnectionActivationState.cs | 3 - ...tionActivationStateCapabilitiesExchange.cs | 27 --- ...onActivationStateConnectionFinalization.cs | 6 - .../RawConnectionActivationStateFinalized.cs | 6 - ...tateCapabilitiesExchangeBoxIronRdpError.cs | 46 ----- .../Generated/RawNetworkCharacteristics.cs | 44 +++++ ...tNetworkCharacteristicsBoxIronRdpError.cs} | 6 +- ffi/src/connector/activation.rs | 89 ++------- ffi/src/session/mod.rs | 55 ++++-- 38 files changed, 677 insertions(+), 690 deletions(-) delete mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateCapabilitiesExchange.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/NetworkCharacteristics.cs delete mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateCapabilitiesExchange.cs delete mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError.cs create mode 100644 ffi/dotnet/Devolutions.IronRdp/Generated/RawNetworkCharacteristics.cs rename ffi/dotnet/Devolutions.IronRdp/Generated/{RawSessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError.cs => RawSessionFfiResultNetworkCharacteristicsBoxIronRdpError.cs} (79%) diff --git a/Cargo.lock b/Cargo.lock index 3f9cfce08e..bf3905f4e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2907,7 +2907,6 @@ name = "ironrdp-session" version = "0.10.0" dependencies = [ "ironrdp-bulk", - "ironrdp-connector", "ironrdp-core", "ironrdp-displaycontrol", "ironrdp-dvc", diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 929fa99601..d9e8bc70f9 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -19,7 +19,7 @@ use ironrdp_pdu::input::mouse::PointerFlags; #[cfg(any(feature = "dvc-pipe-proxy", all(windows, feature = "dvc-com-plugin")))] use ironrdp_pdu::pdu_other_err; use ironrdp_session::image::DecodedImage; -use ironrdp_session::{ActiveStage, ActiveStageOutput, GracefulDisconnectReason, SessionResult, fast_path}; +use ironrdp_session::{ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason, SessionResult, fast_path}; use ironrdp_svc::SvcMessage; use ironrdp_tokio::reqwest::ReqwestNetworkClient; use ironrdp_tokio::{FramedWrite, single_sequence_step_read, split_tokio_framed}; @@ -732,7 +732,20 @@ async fn active_session( let (mut reader, mut writer) = split_tokio_framed(framed); let desktop_size = connection_result.desktop_size; let mut image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); - let mut active_stage = ActiveStage::new(connection_result); + + // We retain the factory to drive the Deactivation-Reactivation Sequence locally. + let activation_factory = connection_result.activation_factory; + + let mut active_stage = ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); // Timer interval for driving clipboard lock timeouts. let mut cleanup_interval = tokio::time::interval(Duration::from_secs(5)); @@ -938,13 +951,14 @@ async fn active_session( .await .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } - ActiveStageOutput::DeactivateAll(mut connection_activation) => { + ActiveStageOutput::DeactivateAll => { // Deactivation-Reactivation Sequence: // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 debug!("Executing Deactivation-Reactivation Sequence"); + let mut connection_activation = activation_factory.create(); let mut buf = WriteBuf::new(); 'activation_seq: loop { - let written = single_sequence_step_read(&mut reader, &mut *connection_activation, &mut buf) + let written = single_sequence_step_read(&mut reader, &mut connection_activation, &mut buf) .await .map_err(|e| { ironrdp_session::custom_err!("read deactivation-reactivation sequence step", e) @@ -955,8 +969,6 @@ async fn active_session( })?; } if let ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, share_id, enable_server_pointer, @@ -967,8 +979,8 @@ async fn active_session( image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); active_stage.set_fastpath_processor( fast_path::ProcessorBuilder { - io_channel_id, - user_channel_id, + io_channel_id: connection_activation.io_channel_id(), + user_channel_id: connection_activation.user_channel_id(), share_id, enable_server_pointer, pointer_software_rendering, diff --git a/crates/ironrdp-connector/src/connection.rs b/crates/ironrdp-connector/src/connection.rs index 55ab284bb2..c4ed3d5cfd 100644 --- a/crates/ironrdp-connector/src/connection.rs +++ b/crates/ironrdp-connector/src/connection.rs @@ -10,7 +10,9 @@ use ironrdp_svc::{StaticChannelSet, StaticVirtualChannel, SvcClientProcessor}; use tracing::{debug, error, info, warn}; use crate::channel_connection::{ChannelConnectionSequence, ChannelConnectionState}; -use crate::connection_activation::{ConnectionActivationSequence, ConnectionActivationState}; +use crate::connection_activation::{ + ConnectionActivationFactory, ConnectionActivationSequence, ConnectionActivationState, +}; use crate::license_exchange::{LicenseExchangeSequence, NoopLicenseCache}; use crate::{ Config, ConnectorError, ConnectorErrorExt as _, ConnectorErrorKind, ConnectorResult, DesktopSize, @@ -26,7 +28,13 @@ pub struct ConnectionResult { pub desktop_size: DesktopSize, pub enable_server_pointer: bool, pub pointer_software_rendering: bool, - pub connection_activation: ConnectionActivationSequence, + /// Factory for producing connection activation sequences. + /// + /// Used to drive the [Deactivation-Reactivation Sequence] when a Server Deactivate All PDU is + /// received: produce a fresh sequence, drive it to completion, then drop it. + /// + /// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 + pub activation_factory: ConnectionActivationFactory, /// The bulk compression type that was negotiated, if any. pub compression_type: Option, } @@ -562,7 +570,7 @@ impl Sequence for ClientConnector { // Server Deactivate All PDU before the Server Demand Active PDU (sent // by e.g. Windows Server and gnome-remote-desktop); mirror it here and // wait for the next input. - ConnectionActivationState::CapabilitiesExchange { .. } => ( + ConnectionActivationState::CapabilitiesExchange => ( written, ClientConnectorState::CapabilitiesExchange { connection_activation }, ), @@ -583,22 +591,24 @@ impl Sequence for ClientConnector { } else { match connection_activation.connection_activation_state() { ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, share_id, enable_server_pointer, pointer_software_rendering, } => ClientConnectorState::Connected { result: ConnectionResult { - io_channel_id, - user_channel_id, + io_channel_id: connection_activation.io_channel_id(), + user_channel_id: connection_activation.user_channel_id(), share_id, static_channels: mem::take(&mut self.static_channels), desktop_size, enable_server_pointer, pointer_software_rendering, - connection_activation, + activation_factory: ConnectionActivationFactory::new( + self.config.clone(), + connection_activation.io_channel_id(), + connection_activation.user_channel_id(), + ), compression_type: self.config.compression_type, }, }, diff --git a/crates/ironrdp-connector/src/connection_activation.rs b/crates/ironrdp-connector/src/connection_activation.rs index 844e7457e8..b1a0ef2d91 100644 --- a/crates/ironrdp-connector/src/connection_activation.rs +++ b/crates/ironrdp-connector/src/connection_activation.rs @@ -25,54 +25,77 @@ use crate::{ pub struct ConnectionActivationSequence { state: ConnectionActivationState, config: Config, + // The MCS channel IDs are invariant for the whole life of the sequence: they are negotiated + // once and never change, even across a Deactivation-Reactivation Sequence. They are stored + // here (rather than duplicated into every state variant). + io_channel_id: u16, + user_channel_id: u16, } impl ConnectionActivationSequence { pub fn new(config: Config, io_channel_id: u16, user_channel_id: u16) -> Self { + // TODO/FIXME: Investigate whether we really need to carry around the whole `Config` struct. + // RATIONALE(@CBenoit): Not very convenient when building in isolation. + // I doubt this type really needs every field there. Self { - state: ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - }, + state: ConnectionActivationState::CapabilitiesExchange, config, + io_channel_id, + user_channel_id, } } - /// Returns the current state as a district type, rather than `&dyn State` provided by [`Self::state`]. + pub fn io_channel_id(&self) -> u16 { + self.io_channel_id + } + + pub fn user_channel_id(&self) -> u16 { + self.user_channel_id + } + + /// Returns the current state as a distinct type, rather than `&dyn State` provided by [`Self::state`]. pub fn connection_activation_state(&self) -> ConnectionActivationState { self.state } +} - #[must_use] - pub fn reset_clone(&self) -> Self { - self.clone().reset() +/// Factory producing fresh [`ConnectionActivationSequence`] instances. +/// +/// The [`Config`] and MCS channel IDs required to build a connection activation sequence are +/// invariant for the whole lifetime of the connection: they are negotiated once and never change, +/// even across a [Deactivation-Reactivation Sequence]. This factory captures them so that a fresh, +/// correctly-initialized sequence can be produced each time one is needed, driven until it is +/// finalized, then dropped. +/// +/// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 +#[derive(Debug, Clone)] +pub struct ConnectionActivationFactory { + config: Config, + io_channel_id: u16, + user_channel_id: u16, +} + +impl ConnectionActivationFactory { + pub fn new(config: Config, io_channel_id: u16, user_channel_id: u16) -> Self { + Self { + config, + io_channel_id, + user_channel_id, + } } - fn reset(mut self) -> Self { - match &self.state { - ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - } - | ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, - .. - } - | ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, - .. - } => { - self.state = ConnectionActivationState::CapabilitiesExchange { - io_channel_id: *io_channel_id, - user_channel_id: *user_channel_id, - }; + pub fn io_channel_id(&self) -> u16 { + self.io_channel_id + } - self - } - ConnectionActivationState::Consumed => self, - } + pub fn user_channel_id(&self) -> u16 { + self.user_channel_id + } + + /// Produces a fresh [`ConnectionActivationSequence`] in the initial `CapabilitiesExchange` state. + #[must_use] + pub fn create(&self) -> ConnectionActivationSequence { + ConnectionActivationSequence::new(self.config.clone(), self.io_channel_id, self.user_channel_id) } } @@ -81,7 +104,7 @@ impl Sequence for ConnectionActivationSequence { match &self.state { ConnectionActivationState::Consumed => None, ConnectionActivationState::Finalized { .. } => None, - ConnectionActivationState::CapabilitiesExchange { .. } => Some(&ironrdp_pdu::X224_HINT), + ConnectionActivationState::CapabilitiesExchange => Some(&ironrdp_pdu::X224_HINT), ConnectionActivationState::ConnectionFinalization { connection_finalization, .. @@ -100,10 +123,7 @@ impl Sequence for ConnectionActivationSequence { "connector sequence state is finalized or consumed (this is a bug)" )); } - ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - } => { + ConnectionActivationState::CapabilitiesExchange => { debug!("Capabilities Exchange"); let send_data_indication_ctx = @@ -113,9 +133,9 @@ impl Sequence for ConnectionActivationSequence { debug!(message = ?share_control_ctx.pdu, "Received"); - if share_control_ctx.channel_id != io_channel_id { + if share_control_ctx.channel_id != self.io_channel_id { warn!( - io_channel_id, + io_channel_id = self.io_channel_id, share_control_ctx.channel_id, "Unexpected channel ID for received Share Control Pdu" ); } @@ -134,10 +154,7 @@ impl Sequence for ConnectionActivationSequence { debug!( "Skipping Server Deactivate All PDU received during Capabilities Exchange, awaiting Server Demand Active" ); - self.state = ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - }; + self.state = ConnectionActivationState::CapabilitiesExchange; return Ok(Written::Nothing); } @@ -194,8 +211,8 @@ impl Sequence for ConnectionActivationSequence { debug!(message = ?client_confirm_active, "Send"); let written = rdp::headers::encode_share_control( - user_channel_id, - io_channel_id, + self.user_channel_id, + self.io_channel_id, share_id, client_confirm_active, output, @@ -205,21 +222,17 @@ impl Sequence for ConnectionActivationSequence { ( Written::from_size(written)?, ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, share_id, connection_finalization: ConnectionFinalizationSequence::new( - io_channel_id, - user_channel_id, + self.io_channel_id, + self.user_channel_id, share_id, ), }, ) } ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, share_id, mut connection_finalization, @@ -230,16 +243,12 @@ impl Sequence for ConnectionActivationSequence { let next_state = if !connection_finalization.state.is_terminal() { ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, share_id, connection_finalization, } } else { ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, share_id, enable_server_pointer: self.config.enable_server_pointer, @@ -261,20 +270,13 @@ impl Sequence for ConnectionActivationSequence { pub enum ConnectionActivationState { #[default] Consumed, - CapabilitiesExchange { - io_channel_id: u16, - user_channel_id: u16, - }, + CapabilitiesExchange, ConnectionFinalization { - io_channel_id: u16, - user_channel_id: u16, desktop_size: DesktopSize, share_id: u32, connection_finalization: ConnectionFinalizationSequence, }, Finalized { - io_channel_id: u16, - user_channel_id: u16, desktop_size: DesktopSize, share_id: u32, enable_server_pointer: bool, @@ -286,7 +288,7 @@ impl State for ConnectionActivationState { fn name(&self) -> &'static str { match self { ConnectionActivationState::Consumed => "Consumed", - ConnectionActivationState::CapabilitiesExchange { .. } => "CapabilitiesExchange", + ConnectionActivationState::CapabilitiesExchange => "CapabilitiesExchange", ConnectionActivationState::ConnectionFinalization { .. } => "ConnectionFinalization", ConnectionActivationState::Finalized { .. } => "Finalized", } diff --git a/crates/ironrdp-session/Cargo.toml b/crates/ironrdp-session/Cargo.toml index a6f2076e27..a62d30e18f 100644 --- a/crates/ironrdp-session/Cargo.toml +++ b/crates/ironrdp-session/Cargo.toml @@ -24,7 +24,6 @@ qoiz = ["dep:zstd-safe", "qoi"] [dependencies] ironrdp-bulk = { path = "../ironrdp-bulk", version = "0.1" } ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public # TODO: at some point, this dependency could be removed (good for compilation speed) ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public diff --git a/crates/ironrdp-session/src/active_stage.rs b/crates/ironrdp-session/src/active_stage.rs index b7bb82446f..1578015b68 100644 --- a/crates/ironrdp-session/src/active_stage.rs +++ b/crates/ironrdp-session/src/active_stage.rs @@ -1,8 +1,6 @@ use std::sync::Arc; use ironrdp_bulk::BulkCompressor; -use ironrdp_connector::ConnectionResult; -use ironrdp_connector::connection_activation::ConnectionActivationSequence; use ironrdp_core::{ReadCursor, WriteBuf}; use ironrdp_displaycontrol::client::DisplayControlClient; use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel}; @@ -15,7 +13,7 @@ use ironrdp_pdu::rdp::headers::ShareDataPdu; use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu; use ironrdp_pdu::slow_path::{self, GraphicsUpdateType}; use ironrdp_pdu::{Action, mcs}; -use ironrdp_svc::{SvcMessage, SvcProcessor, SvcProcessorMessages}; +use ironrdp_svc::{StaticChannelSet, SvcMessage, SvcProcessor, SvcProcessorMessages}; use tracing::{debug, info, warn}; use crate::fast_path::UpdateKind; @@ -38,18 +36,39 @@ pub struct ActiveStage { enable_server_pointer: bool, } -impl ActiveStage { - pub fn new(connection_result: ConnectionResult) -> Self { - let x224_processor = x224::Processor::new( - connection_result.static_channels, - connection_result.user_channel_id, - connection_result.io_channel_id, - connection_result.share_id, - connection_result.connection_activation, - ); +/// Builder for [`ActiveStage`]. +/// +/// All fields are required; they are typically taken straight from `ironrdp-connector`’s +/// `ConnectionResult` once the connection sequence is finalized. +pub struct ActiveStageBuilder { + pub static_channels: StaticChannelSet, + pub user_channel_id: u16, + pub io_channel_id: u16, + pub share_id: u32, + /// The bulk compression type that was negotiated, if any. + pub compression_type: Option, + /// Enable server-side pointer updates (client-side pointer rendering). + pub enable_server_pointer: bool, + /// Use software rendering mode for pointer bitmap generation. + pub pointer_software_rendering: bool, +} + +impl ActiveStageBuilder { + pub fn build(self) -> ActiveStage { + let Self { + static_channels, + user_channel_id, + io_channel_id, + share_id, + compression_type, + enable_server_pointer, + pointer_software_rendering, + } = self; + + let x224_processor = x224::Processor::new(static_channels, user_channel_id, io_channel_id, share_id); // Create bulk decompressor if compression was negotiated - let bulk_decompressor = connection_result.compression_type.and_then(|ct| { + let bulk_decompressor = compression_type.and_then(|ct| { let bulk_ct = to_bulk_compression_type(ct); match BulkCompressor::new(bulk_ct) { Ok(compressor) => { @@ -64,22 +83,24 @@ impl ActiveStage { }); let fast_path_processor = fast_path::ProcessorBuilder { - io_channel_id: connection_result.io_channel_id, - user_channel_id: connection_result.user_channel_id, - share_id: connection_result.share_id, - enable_server_pointer: connection_result.enable_server_pointer, - pointer_software_rendering: connection_result.pointer_software_rendering, + io_channel_id, + user_channel_id, + share_id, + enable_server_pointer, + pointer_software_rendering, bulk_decompressor, } .build(); - Self { + ActiveStage { x224_processor, fast_path_processor, - enable_server_pointer: connection_result.enable_server_pointer, + enable_server_pointer, } } +} +impl ActiveStage { pub fn update_mouse_pos(&mut self, x: u16, y: u16) { self.fast_path_processor.update_mouse_pos(x, y); } @@ -320,7 +341,10 @@ pub enum ActiveStageOutput { }, PointerBitmap(Arc), Terminate(GracefulDisconnectReason), - DeactivateAll(Box), + /// Received a Server Deactivate All PDU. The consumer should execute the [Deactivation-Reactivation Sequence]. + /// + /// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 + DeactivateAll, /// Server Initiate Multitransport Request. The application should establish a /// sideband UDP transport using the provided request parameters. /// @@ -357,7 +381,7 @@ impl TryFrom for ActiveStageOutput { Ok(Self::Terminate(desc)) } - x224::ProcessorOutput::DeactivateAll(cas) => Ok(Self::DeactivateAll(cas)), + x224::ProcessorOutput::DeactivateAll => Ok(Self::DeactivateAll), x224::ProcessorOutput::MultitransportRequest(pdu) => Ok(Self::MultitransportRequest(pdu)), x224::ProcessorOutput::AutoDetect(request) => Ok(Self::AutoDetect(request)), // GraphicsUpdate and PointerUpdate are consumed in ActiveStage::process() diff --git a/crates/ironrdp-session/src/lib.rs b/crates/ironrdp-session/src/lib.rs index cf9862a968..4d45fad1fb 100644 --- a/crates/ironrdp-session/src/lib.rs +++ b/crates/ironrdp-session/src/lib.rs @@ -15,7 +15,7 @@ mod palette; use core::fmt; -pub use active_stage::{ActiveStage, ActiveStageOutput, GracefulDisconnectReason}; +pub use active_stage::{ActiveStage, ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason}; pub type SessionResult = Result; diff --git a/crates/ironrdp-session/src/x224/mod.rs b/crates/ironrdp-session/src/x224/mod.rs index 74f471cff6..a7271f987e 100644 --- a/crates/ironrdp-session/src/x224/mod.rs +++ b/crates/ironrdp-session/src/x224/mod.rs @@ -1,4 +1,3 @@ -use ironrdp_connector::connection_activation::ConnectionActivationSequence; use ironrdp_core::WriteBuf; use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel}; use ironrdp_pdu::mcs::{DisconnectProviderUltimatum, DisconnectReason, McsMessage, SendDataIndicationCtx}; @@ -23,7 +22,7 @@ pub enum ProcessorOutput { /// [Deactivation-Reactivation Sequence]. /// /// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 - DeactivateAll(Box), + DeactivateAll, /// Server Initiate Multitransport Request. The application should establish a /// sideband UDP transport using the request ID and security cookie, then send /// a [`MultitransportResponsePdu`] back on the IO channel. @@ -65,23 +64,15 @@ pub struct Processor { user_channel_id: u16, io_channel_id: u16, share_id: u32, - connection_activation: ConnectionActivationSequence, } impl Processor { - pub fn new( - static_channels: StaticChannelSet, - user_channel_id: u16, - io_channel_id: u16, - share_id: u32, - connection_activation: ConnectionActivationSequence, - ) -> Self { + pub fn new(static_channels: StaticChannelSet, user_channel_id: u16, io_channel_id: u16, share_id: u32) -> Self { Self { static_channels, user_channel_id, io_channel_id, share_id, - connection_activation, } } @@ -245,9 +236,7 @@ impl Processor { ); Ok(vec![ProcessorOutput::MultitransportRequest(pdu)]) } - ironrdp_pdu::rdp::headers::IoChannelPdu::DeactivateAll(_) => Ok(vec![ProcessorOutput::DeactivateAll( - Box::new(self.connection_activation.reset_clone()), - )]), + ironrdp_pdu::rdp::headers::IoChannelPdu::DeactivateAll(_) => Ok(vec![ProcessorOutput::DeactivateAll]), } } diff --git a/crates/ironrdp-testsuite-core/tests/session/autodetect.rs b/crates/ironrdp-testsuite-core/tests/session/autodetect.rs index 9e931deae2..1d8e8f0eef 100644 --- a/crates/ironrdp-testsuite-core/tests/session/autodetect.rs +++ b/crates/ironrdp-testsuite-core/tests/session/autodetect.rs @@ -1,12 +1,8 @@ use std::borrow::Cow; -use ironrdp_connector::connection_activation::ConnectionActivationSequence; -use ironrdp_connector::{Credentials, DesktopSize}; use ironrdp_core::encode_vec; -use ironrdp_pdu::gcc; use ironrdp_pdu::mcs::{McsMessage, SendDataIndication}; use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; -use ironrdp_pdu::rdp::capability_sets::MajorPlatformType; use ironrdp_pdu::rdp::client_info::CompressionType; use ironrdp_pdu::rdp::headers::{ CompressionFlags, ShareControlHeader, ShareControlPdu, ShareDataHeader, ShareDataPdu, StreamPriority, @@ -19,51 +15,8 @@ const USER_CHANNEL_ID: u16 = 1002; const IO_CHANNEL_ID: u16 = 1003; const SHARE_ID: u32 = 0x0001_0000; -fn test_config() -> ironrdp_connector::Config { - ironrdp_connector::Config { - desktop_size: DesktopSize { - width: 1024, - height: 768, - }, - desktop_scale_factor: 0, - enable_tls: true, - enable_credssp: false, - credentials: Credentials::UsernamePassword { - username: "test".into(), - password: "test".into(), - }, - domain: None, - client_build: 0, - client_name: "test".into(), - keyboard_type: gcc::KeyboardType::IbmEnhanced, - keyboard_subtype: 0, - keyboard_layout: 0, - keyboard_functional_keys_count: 12, - ime_file_name: String::new(), - bitmap: None, - dig_product_id: String::new(), - client_dir: String::new(), - platform: MajorPlatformType::UNIX, - hardware_id: None, - request_data: None, - autologon: false, - enable_audio_playback: false, - license_cache: None, - compression_type: None, - enable_server_pointer: false, - pointer_software_rendering: false, - multitransport_flags: None, - performance_flags: Default::default(), - timezone_info: Default::default(), - alternate_shell: String::new(), - work_dir: String::new(), - } -} - fn make_processor() -> Processor { - let config = test_config(); - let cas = ConnectionActivationSequence::new(config, IO_CHANNEL_ID, USER_CHANNEL_ID); - Processor::new(StaticChannelSet::new(), USER_CHANNEL_ID, IO_CHANNEL_ID, SHARE_ID, cas) + Processor::new(StaticChannelSet::new(), USER_CHANNEL_ID, IO_CHANNEL_ID, SHARE_ID) } /// Encode a ShareDataPdu as a server-to-client SendDataIndication frame. diff --git a/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs b/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs index d865dba60d..c7ccbf4f50 100644 --- a/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs +++ b/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs @@ -89,7 +89,7 @@ fn deactivate_all_during_capabilities_exchange_stays_in_same_state() { assert!( matches!( seq.connection_activation_state(), - ConnectionActivationState::CapabilitiesExchange { .. } + ConnectionActivationState::CapabilitiesExchange ), "state should remain CapabilitiesExchange after DeactivateAll" ); diff --git a/crates/ironrdp-testsuite-extra/tests/e2e.rs b/crates/ironrdp-testsuite-extra/tests/e2e.rs index 04dfbbd45e..bcb8de3a09 100644 --- a/crates/ironrdp-testsuite-extra/tests/e2e.rs +++ b/crates/ironrdp-testsuite-extra/tests/e2e.rs @@ -16,7 +16,7 @@ use ironrdp::server::{ RdpServerDisplayUpdates, RdpServerInputHandler, ServerEvent, TlsIdentityCtx, }; use ironrdp::session::image::DecodedImage; -use ironrdp::session::{self, ActiveStage, ActiveStageOutput}; +use ironrdp::session::{self, ActiveStage, ActiveStageBuilder, ActiveStageOutput}; use ironrdp_async::{Framed, FramedWrite as _}; use ironrdp_testsuite_extra as _; use ironrdp_tls::TlsStream; @@ -33,9 +33,10 @@ const PASSWORD: &str = ""; #[tokio::test] async fn test_client_server() { - client_server(default_client_config(), |stage, framed, _display_tx| async { - (stage, framed) - }) + client_server( + default_client_config(), + |stage, _activation_factory, framed, _display_tx| async { (stage, framed) }, + ) .await } @@ -47,77 +48,81 @@ async fn test_deactivation_reactivation() { client_config.desktop_size.width, client_config.desktop_size.height, ); - client_server(client_config, |mut stage, mut framed, display_tx| async move { - display_tx - .send(DisplayUpdate::Resize(DesktopSize { - width: 2048, - height: 2048, - })) - .unwrap(); - { - let (action, payload) = framed.read_pdu().await.expect("valid PDU"); - let outputs = stage.process(&mut image, action, &payload).expect("stage process"); - let out = outputs.into_iter().next().unwrap(); - match out { - ActiveStageOutput::DeactivateAll(mut connection_activation) => { - // TODO: factor this out in common client code - // Execute the Deactivation-Reactivation Sequence: - // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 - debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); - let mut buf = pdu::WriteBuf::new(); - 'activation_seq: loop { - let written = ironrdp_async::single_sequence_step_read( - &mut framed, - &mut *connection_activation, - &mut buf, - ) - .await - .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e)) - .unwrap(); - - if written.size().is_some() { - framed - .write_all(buf.filled()) - .await - .map_err(|e| session::custom_err!("write deactivation-reactivation sequence step", e)) - .unwrap(); - } - - if let connector::connection_activation::ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, - desktop_size, - share_id, - enable_server_pointer, - pointer_software_rendering, - } = connection_activation.connection_activation_state() - { - debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); - // Update image size with the new desktop size. - // image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); - // Update the active stage with the new channel IDs and pointer settings. - stage.set_fastpath_processor( - session::fast_path::ProcessorBuilder { - io_channel_id, - user_channel_id, - share_id, - enable_server_pointer, - pointer_software_rendering, - bulk_decompressor: None, - } - .build(), - ); - stage.set_share_id(share_id); - stage.set_enable_server_pointer(enable_server_pointer); - break 'activation_seq; + client_server( + client_config, + |mut stage, activation_factory, mut framed, display_tx| async move { + display_tx + .send(DisplayUpdate::Resize(DesktopSize { + width: 2048, + height: 2048, + })) + .unwrap(); + { + let (action, payload) = framed.read_pdu().await.expect("valid PDU"); + let outputs = stage.process(&mut image, action, &payload).expect("stage process"); + let out = outputs.into_iter().next().unwrap(); + match out { + ActiveStageOutput::DeactivateAll => { + // TODO: factor this out in common client code + // Execute the Deactivation-Reactivation Sequence: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 + debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); + let mut connection_activation = activation_factory.create(); + let mut buf = pdu::WriteBuf::new(); + 'activation_seq: loop { + let written = ironrdp_async::single_sequence_step_read( + &mut framed, + &mut connection_activation, + &mut buf, + ) + .await + .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e)) + .unwrap(); + + if written.size().is_some() { + framed + .write_all(buf.filled()) + .await + .map_err(|e| { + session::custom_err!("write deactivation-reactivation sequence step", e) + }) + .unwrap(); + } + + if let connector::connection_activation::ConnectionActivationState::Finalized { + desktop_size, + share_id, + enable_server_pointer, + pointer_software_rendering, + } = connection_activation.connection_activation_state() + { + debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); + // Update image size with the new desktop size. + // image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); + // Update the active stage with the new channel IDs and pointer settings. + stage.set_fastpath_processor( + session::fast_path::ProcessorBuilder { + io_channel_id: connection_activation.io_channel_id(), + user_channel_id: connection_activation.user_channel_id(), + share_id, + enable_server_pointer, + pointer_software_rendering, + bulk_decompressor: None, + } + .build(), + ); + stage.set_share_id(share_id); + stage.set_enable_server_pointer(enable_server_pointer); + break 'activation_seq; + } } } + _ => unreachable!(), } - _ => unreachable!(), } - } - (stage, framed) - }) + (stage, framed) + }, + ) .await } @@ -129,7 +134,7 @@ async fn test_echo_virtual_channel_end_to_end() { client_server_with_connector( default_client_config(), |connector| connector.with_static_channel(DrdynvcClient::new().with_dynamic_channel(EchoClient::new())), - move |mut stage, mut framed, display_tx, echo_handle| async move { + move |mut stage, _activation_factory, mut framed, display_tx, echo_handle| async move { let _display_tx = display_tx; let mut image = DecodedImage::new(PixelFormat::RgbA32, DESKTOP_WIDTH, DESKTOP_HEIGHT); @@ -218,13 +223,21 @@ impl RdpServerInputHandler for TestInputHandler { async fn client_server(client_config: connector::Config, clientfn: F) where - F: FnOnce(ActiveStage, Framed>>, UnboundedSender) -> Fut + 'static, + F: FnOnce( + ActiveStage, + connector::connection_activation::ConnectionActivationFactory, + Framed>>, + UnboundedSender, + ) -> Fut + + 'static, Fut: Future>>)>, { client_server_with_connector( client_config, |connector| connector, - move |stage, framed, display_tx, _echo_handle| clientfn(stage, framed, display_tx), + move |stage, connection_activation, framed, display_tx, _echo_handle| { + clientfn(stage, connection_activation, framed, display_tx) + }, ) .await; } @@ -233,6 +246,7 @@ async fn client_server_with_connector(client_config: connector::Confi where F: FnOnce( ActiveStage, + connector::connection_activation::ConnectionActivationFactory, Framed>>, UnboundedSender, server::EchoServerHandle, @@ -241,6 +255,7 @@ where Fut: Future>>)>, C: FnOnce(connector::ClientConnector) -> connector::ClientConnector + 'static, { + // FIXME(@CBenoit): If this is really necessary, we may consider a non-global way of registering the subscriber; otherwise it’s unnecessary to register that. let _ = tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .try_init(); @@ -306,9 +321,27 @@ where .await .expect("finalize connection"); - let active_stage = ActiveStage::new(connection_result); - let (active_stage, mut upgraded_framed) = - clientfn(active_stage, upgraded_framed, display_tx, echo_handle).await; + // Retain the connection activation factory so the client closure can drive its own + // Deactivation-Reactivation Sequence. + let activation_factory = connection_result.activation_factory; + let active_stage = ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); + let (active_stage, mut upgraded_framed) = clientfn( + active_stage, + activation_factory, + upgraded_framed, + display_tx, + echo_handle, + ) + .await; let outputs = active_stage.graceful_shutdown().expect("shutdown"); for out in outputs { match out { diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index 02d8adfbd4..11bc06950f 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -30,7 +30,7 @@ use ironrdp::rdpdr::Rdpdr; use ironrdp::rdpdr::pdu::efs::{DEFAULT_PRINTER_DRIVER_NAME, MICROSOFT_PRINT_TO_PDF_DRIVER_NAME}; use ironrdp::rdpsnd::client::{NoopRdpsndBackend, Rdpsnd}; use ironrdp::session::image::DecodedImage; -use ironrdp::session::{ActiveStage, ActiveStageOutput, GracefulDisconnectReason, fast_path}; +use ironrdp::session::{ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason, fast_path}; use ironrdp_core::WriteBuf; use ironrdp_futures::{FramedWrite, single_sequence_step_read}; use rgb::AsPixels as _; @@ -660,7 +660,19 @@ impl iron_remote_desktop::Session for Session { // Reused across frames so per-region extraction doesn't allocate on every draw. let mut draw_buffer = WriteBuf::new(); - let mut active_stage = ActiveStage::new(connection_result); + // We retain the factory to drive the Deactivation-Reactivation Sequence locally. + let activation_factory = connection_result.activation_factory; + + let mut active_stage = ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); // Timer interval for driving clipboard lock timeouts (5 second interval) let mut cleanup_interval = IntervalStream::new(5_000).fuse(); @@ -987,7 +999,7 @@ impl iron_remote_desktop::Session for Session { hotspot_y, })?; } - ActiveStageOutput::DeactivateAll(mut box_connection_activation) => { + ActiveStageOutput::DeactivateAll => { // Execute the Deactivation-Reactivation Sequence: // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); @@ -999,11 +1011,11 @@ impl iron_remote_desktop::Session for Session { requested_resize = None; } + let mut connection_activation = activation_factory.create(); let mut buf = WriteBuf::new(); 'activation_seq: loop { let written = - single_sequence_step_read(&mut framed, &mut *box_connection_activation, &mut buf) - .await?; + single_sequence_step_read(&mut framed, &mut connection_activation, &mut buf).await?; if written.size().is_some() { self.writer_tx @@ -1012,13 +1024,11 @@ impl iron_remote_desktop::Session for Session { } if let ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, share_id, enable_server_pointer, pointer_software_rendering, - } = box_connection_activation.connection_activation_state() + } = connection_activation.connection_activation_state() { debug!("Deactivation-Reactivation Sequence completed"); image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); @@ -1026,8 +1036,8 @@ impl iron_remote_desktop::Session for Session { // io/user channel ids. active_stage.set_fastpath_processor( fast_path::ProcessorBuilder { - io_channel_id, - user_channel_id, + io_channel_id: connection_activation.io_channel_id(), + user_channel_id: connection_activation.user_channel_id(), share_id, enable_server_pointer, pointer_software_rendering, diff --git a/crates/ironrdp/examples/screenshot.rs b/crates/ironrdp/examples/screenshot.rs index d253336909..a5d82fca10 100644 --- a/crates/ironrdp/examples/screenshot.rs +++ b/crates/ironrdp/examples/screenshot.rs @@ -29,7 +29,7 @@ use ironrdp::connector::ConnectionResult; use ironrdp::pdu::gcc::KeyboardType; use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; use ironrdp::session::image::DecodedImage; -use ironrdp::session::{ActiveStage, ActiveStageOutput}; +use ironrdp::session::{ActiveStageBuilder, ActiveStageOutput}; use ironrdp_pdu::rdp::client_info::{CompressionType, PerformanceFlags, TimezoneInfo}; use sspi::network_client::reqwest_network_client::ReqwestNetworkClient; use tokio_rustls::rustls; @@ -344,7 +344,16 @@ fn active_stage( mut framed: UpgradedFramed, image: &mut DecodedImage, ) -> anyhow::Result<()> { - let mut active_stage = ActiveStage::new(connection_result); + let mut active_stage = ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); 'outer: loop { let (action, payload) = match framed.read_pdu() { diff --git a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs index 2b938c5053..ee8c52c496 100644 --- a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs +++ b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs @@ -516,7 +516,7 @@ private async Task HandleActiveStageOutput(ActiveStageOutputIterator outpu } else if (output.GetEnumType() == ActiveStageOutputType.DeactivateAll) { - var activationSequence = output.GetDeactivateAll(); + var activationSequence = _activeStage!.CreateConnectionActivation(); var writeBuf = WriteBuf.New(); while (true) { @@ -527,8 +527,8 @@ private async Task HandleActiveStageOutput(ActiveStageOutputIterator outpu var finalized = activationSequence.GetState().GetFinalized(); var desktopSize = finalized.GetDesktopSize(); - var ioChannelId = finalized.GetIoChannelId(); - var userChannelId = finalized.GetUserChannelId(); + var ioChannelId = activationSequence.GetIoChannelId(); + var userChannelId = activationSequence.GetUserChannelId(); var shareId = finalized.GetShareId(); var enableServerPointer = finalized.GetEnableServerPointer(); var pointerSoftwareRendering = finalized.GetPointerSoftwareRendering(); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs index 61854d4127..b6a0f3079a 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs @@ -61,6 +61,30 @@ public static ActiveStage New(ConnectionResult connectionResult) } } + ///

+ /// Produces a fresh connection activation sequence to drive the Deactivation-Reactivation + /// Sequence. + /// + /// + /// Call this upon receiving a [`ActiveStageOutputType::DeactivateAll`] output, drive the + /// returned sequence until it is finalized, then discard it. + /// + /// + /// A ConnectionActivationSequence allocated on Rust side. + /// + public ConnectionActivationSequence CreateConnectionActivation() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ActiveStage"); + } + Raw.ConnectionActivationSequence* retVal = Raw.ActiveStage.CreateConnectionActivation(_inner); + return new ConnectionActivationSequence(retVal); + } + } + /// /// /// A ActiveStageOutputIterator allocated on Rust side. diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutput.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutput.cs index 83c0d089e4..a9f5f82a42 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutput.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutput.cs @@ -15,11 +15,11 @@ public partial class ActiveStageOutput: IDisposable { private unsafe Raw.ActiveStageOutput* _inner; - public ConnectionActivationSequence DeactivateAll + public NetworkCharacteristics AutodetectNetworkCharacteristics { get { - return GetDeactivateAll(); + return GetAutodetectNetworkCharacteristics(); } } @@ -219,11 +219,18 @@ public GracefulDisconnectReason GetTerminate() } } + /// + /// Returns the multitransport request ID and requested protocol. + /// + /// + /// The security cookie is intentionally not exposed — it is sensitive + /// and only needed internally for transport binding. + /// /// /// - /// A ConnectionActivationSequence allocated on Rust side. + /// A MultitransportRequest allocated on C# side. /// - public ConnectionActivationSequence GetDeactivateAll() + public MultitransportRequest GetMultitransportRequest() { unsafe { @@ -231,28 +238,27 @@ public ConnectionActivationSequence GetDeactivateAll() { throw new ObjectDisposedException("ActiveStageOutput"); } - Raw.SessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError result = Raw.ActiveStageOutput.GetDeactivateAll(_inner); + Raw.SessionFfiResultMultitransportRequestBoxIronRdpError result = Raw.ActiveStageOutput.GetMultitransportRequest(_inner); if (!result.isOk) { throw new IronRdpException(new IronRdpError(result.Err)); } - Raw.ConnectionActivationSequence* retVal = result.Ok; - return new ConnectionActivationSequence(retVal); + Raw.MultitransportRequest retVal = result.Ok; + return new MultitransportRequest(retVal); } } /// - /// Returns the multitransport request ID and requested protocol. + /// Connection quality signals from the server's auto-detect mechanism. + /// Returns RTT and bandwidth measurements for health monitoring. + /// These values will feed into FramePacingFeedback when the + /// library-level health observer traits from #1158 land. /// - /// - /// The security cookie is intentionally not exposed — it is sensitive - /// and only needed internally for transport binding. - /// /// /// - /// A MultitransportRequest allocated on C# side. + /// A NetworkCharacteristics allocated on C# side. /// - public MultitransportRequest GetMultitransportRequest() + public NetworkCharacteristics GetAutodetectNetworkCharacteristics() { unsafe { @@ -260,13 +266,13 @@ public MultitransportRequest GetMultitransportRequest() { throw new ObjectDisposedException("ActiveStageOutput"); } - Raw.SessionFfiResultMultitransportRequestBoxIronRdpError result = Raw.ActiveStageOutput.GetMultitransportRequest(_inner); + Raw.SessionFfiResultNetworkCharacteristicsBoxIronRdpError result = Raw.ActiveStageOutput.GetAutodetectNetworkCharacteristics(_inner); if (!result.isOk) { throw new IronRdpException(new IronRdpError(result.Err)); } - Raw.MultitransportRequest retVal = result.Ok; - return new MultitransportRequest(retVal); + Raw.NetworkCharacteristics retVal = result.Ok; + return new NetworkCharacteristics(retVal); } } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutputType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutputType.cs index e91b59cd96..1b3f995537 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutputType.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutputType.cs @@ -22,4 +22,10 @@ public enum ActiveStageOutputType Terminate = 6, DeactivateAll = 7, MultitransportRequest = 8, + /// + /// Auto-detect network characteristics from server. + /// Use `get_autodetect_network_characteristics()` to retrieve + /// RTT and bandwidth values for connection quality monitoring. + /// + AutoDetect = 9, } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessageType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessageType.cs index a1a801ade9..fcd8ef6381 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessageType.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessageType.cs @@ -14,9 +14,10 @@ namespace Devolutions.IronRdp; public enum ClipboardMessageType { SendInitiateCopy = 0, - SendFormatData = 1, - SendInitiatePaste = 2, - SendFileContentsRequest = 3, - SendFileContentsResponse = 4, - Error = 5, + SendInitiateFileCopy = 1, + SendFormatData = 2, + SendInitiatePaste = 3, + SendFileContentsRequest = 4, + SendFileContentsResponse = 5, + Error = 6, } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationSequence.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationSequence.cs index e0d8700719..1abefa8164 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationSequence.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationSequence.cs @@ -15,6 +15,14 @@ public partial class ConnectionActivationSequence: IDisposable { private unsafe Raw.ConnectionActivationSequence* _inner; + public ushort IoChannelId + { + get + { + return GetIoChannelId(); + } + } + public ConnectionActivationState State { get @@ -23,6 +31,14 @@ public ConnectionActivationState State } } + public ushort UserChannelId + { + get + { + return GetUserChannelId(); + } + } + /// /// Creates a managed ConnectionActivationSequence from a raw handle. /// @@ -139,6 +155,32 @@ public Written StepNoInput(WriteBuf buf) } } + public ushort GetIoChannelId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ConnectionActivationSequence"); + } + ushort retVal = Raw.ConnectionActivationSequence.GetIoChannelId(_inner); + return retVal; + } + } + + public ushort GetUserChannelId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ConnectionActivationSequence"); + } + ushort retVal = Raw.ConnectionActivationSequence.GetUserChannelId(_inner); + return retVal; + } + } + /// /// Returns the underlying raw handle. /// diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationState.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationState.cs index 3b6052bcc4..4c238c7743 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationState.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationState.cs @@ -15,14 +15,6 @@ public partial class ConnectionActivationState: IDisposable { private unsafe Raw.ConnectionActivationState* _inner; - public ConnectionActivationStateCapabilitiesExchange CapabilitiesExchange - { - get - { - return GetCapabilitiesExchange(); - } - } - public ConnectionActivationStateConnectionFinalization ConnectionFinalization { get @@ -77,28 +69,6 @@ public ConnectionActivationStateType GetType() } } - /// - /// - /// A ConnectionActivationStateCapabilitiesExchange allocated on Rust side. - /// - public ConnectionActivationStateCapabilitiesExchange GetCapabilitiesExchange() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationState"); - } - Raw.ConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError result = Raw.ConnectionActivationState.GetCapabilitiesExchange(_inner); - if (!result.isOk) - { - throw new IronRdpException(new IronRdpError(result.Err)); - } - Raw.ConnectionActivationStateCapabilitiesExchange* retVal = result.Ok; - return new ConnectionActivationStateCapabilitiesExchange(retVal); - } - } - /// /// /// A ConnectionActivationStateConnectionFinalization allocated on Rust side. diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateCapabilitiesExchange.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateCapabilitiesExchange.cs deleted file mode 100644 index 4b47b9ed96..0000000000 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateCapabilitiesExchange.cs +++ /dev/null @@ -1,105 +0,0 @@ -// by Diplomat - -#pragma warning disable 0105 -using System; -using System.Runtime.InteropServices; - -using Devolutions.IronRdp.Diplomat; -#pragma warning restore 0105 - -namespace Devolutions.IronRdp; - -#nullable enable - -public partial class ConnectionActivationStateCapabilitiesExchange: IDisposable -{ - private unsafe Raw.ConnectionActivationStateCapabilitiesExchange* _inner; - - public ushort IoChannelId - { - get - { - return GetIoChannelId(); - } - } - - public ushort UserChannelId - { - get - { - return GetUserChannelId(); - } - } - - /// - /// Creates a managed ConnectionActivationStateCapabilitiesExchange from a raw handle. - /// - /// - /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). - ///
- /// This constructor assumes the raw struct is allocated on Rust side. - /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. - ///
- public unsafe ConnectionActivationStateCapabilitiesExchange(Raw.ConnectionActivationStateCapabilitiesExchange* handle) - { - _inner = handle; - } - - public ushort GetIoChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateCapabilitiesExchange"); - } - ushort retVal = Raw.ConnectionActivationStateCapabilitiesExchange.GetIoChannelId(_inner); - return retVal; - } - } - - public ushort GetUserChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateCapabilitiesExchange"); - } - ushort retVal = Raw.ConnectionActivationStateCapabilitiesExchange.GetUserChannelId(_inner); - return retVal; - } - } - - /// - /// Returns the underlying raw handle. - /// - public unsafe Raw.ConnectionActivationStateCapabilitiesExchange* AsFFI() - { - return _inner; - } - - /// - /// Destroys the underlying object immediately. - /// - public void Dispose() - { - unsafe - { - if (_inner == null) - { - return; - } - - Raw.ConnectionActivationStateCapabilitiesExchange.Destroy(_inner); - _inner = null; - - GC.SuppressFinalize(this); - } - } - - ~ConnectionActivationStateCapabilitiesExchange() - { - Dispose(); - } -} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateConnectionFinalization.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateConnectionFinalization.cs index 8011fbfa8b..545817e335 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateConnectionFinalization.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateConnectionFinalization.cs @@ -23,22 +23,6 @@ public DesktopSize DesktopSize } } - public ushort IoChannelId - { - get - { - return GetIoChannelId(); - } - } - - public ushort UserChannelId - { - get - { - return GetUserChannelId(); - } - } - /// /// Creates a managed ConnectionActivationStateConnectionFinalization from a raw handle. /// @@ -53,32 +37,6 @@ public unsafe ConnectionActivationStateConnectionFinalization(Raw.ConnectionActi _inner = handle; } - public ushort GetIoChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateConnectionFinalization"); - } - ushort retVal = Raw.ConnectionActivationStateConnectionFinalization.GetIoChannelId(_inner); - return retVal; - } - } - - public ushort GetUserChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateConnectionFinalization"); - } - ushort retVal = Raw.ConnectionActivationStateConnectionFinalization.GetUserChannelId(_inner); - return retVal; - } - } - /// /// A DesktopSize allocated on Rust side. /// diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateFinalized.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateFinalized.cs index 8e24dafd70..a14f588eac 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateFinalized.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateFinalized.cs @@ -31,14 +31,6 @@ public bool EnableServerPointer } } - public ushort IoChannelId - { - get - { - return GetIoChannelId(); - } - } - public bool PointerSoftwareRendering { get @@ -55,14 +47,6 @@ public uint ShareId } } - public ushort UserChannelId - { - get - { - return GetUserChannelId(); - } - } - /// /// Creates a managed ConnectionActivationStateFinalized from a raw handle. /// @@ -77,32 +61,6 @@ public unsafe ConnectionActivationStateFinalized(Raw.ConnectionActivationStateFi _inner = handle; } - public ushort GetIoChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateFinalized"); - } - ushort retVal = Raw.ConnectionActivationStateFinalized.GetIoChannelId(_inner); - return retVal; - } - } - - public ushort GetUserChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateFinalized"); - } - ushort retVal = Raw.ConnectionActivationStateFinalized.GetUserChannelId(_inner); - return retVal; - } - } - public uint GetShareId() { unsafe diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/NetworkCharacteristics.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/NetworkCharacteristics.cs new file mode 100644 index 0000000000..be08be0468 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/NetworkCharacteristics.cs @@ -0,0 +1,137 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +/// +/// Connection quality measurements from server auto-detect (MS-RDPBCGR 2.2.14). +/// +public partial class NetworkCharacteristics +{ + private Raw.NetworkCharacteristics _inner; + + /// + /// Lowest detected round-trip time in milliseconds. + /// Only valid when `has_base_rtt` is true. + /// + public uint BaseRttMs + { + get + { + unsafe + { + return _inner.base_rtt_ms; + } + } + set + { + unsafe + { + _inner.base_rtt_ms = value; + } + } + } + + public bool HasBaseRtt + { + get + { + unsafe + { + return _inner.has_base_rtt; + } + } + set + { + unsafe + { + _inner.has_base_rtt = value; + } + } + } + + /// + /// Current average round-trip time in milliseconds. + /// + public uint AverageRttMs + { + get + { + unsafe + { + return _inner.average_rtt_ms; + } + } + set + { + unsafe + { + _inner.average_rtt_ms = value; + } + } + } + + /// + /// Estimated bandwidth in kilobits per second. + /// Only valid when `has_bandwidth` is true. + /// + public uint BandwidthKbps + { + get + { + unsafe + { + return _inner.bandwidth_kbps; + } + } + set + { + unsafe + { + _inner.bandwidth_kbps = value; + } + } + } + + public bool HasBandwidth + { + get + { + unsafe + { + return _inner.has_bandwidth; + } + } + set + { + unsafe + { + _inner.has_bandwidth = value; + } + } + } + + /// + /// Creates a managed NetworkCharacteristics from the raw representation. + /// + public unsafe NetworkCharacteristics(Raw.NetworkCharacteristics data) + { + _inner = data; + } + + /// + /// Returns a copy of the underlying raw representation. + /// + public Raw.NetworkCharacteristics AsFFI() + { + return _inner; + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs index 84a81530f1..005417238b 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs @@ -19,6 +19,17 @@ public partial struct ActiveStage [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_new", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxActiveStageBoxIronRdpError New(ConnectionResult* connectionResult); + /// + /// Produces a fresh connection activation sequence to drive the Deactivation-Reactivation + /// Sequence. + /// + /// + /// Call this upon receiving a [`ActiveStageOutputType::DeactivateAll`] output, drive the + /// returned sequence until it is finalized, then discard it. + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_create_connection_activation", ExactSpelling = true)] + public static unsafe extern ConnectionActivationSequence* CreateConnectionActivation(ActiveStage* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_process", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxActiveStageOutputIteratorBoxIronRdpError Process(ActiveStage* self, DecodedImage* image, Action* action, byte* payload, nuint payloadSz); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutput.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutput.cs index 6eee0d7e90..8c56dee46a 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutput.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutput.cs @@ -34,9 +34,6 @@ public partial struct ActiveStageOutput [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_get_terminate", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxGracefulDisconnectReasonBoxIronRdpError GetTerminate(ActiveStageOutput* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_get_deactivate_all", ExactSpelling = true)] - public static unsafe extern SessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError GetDeactivateAll(ActiveStageOutput* self); - /// /// Returns the multitransport request ID and requested protocol. /// @@ -47,6 +44,15 @@ public partial struct ActiveStageOutput [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_get_multitransport_request", ExactSpelling = true)] public static unsafe extern SessionFfiResultMultitransportRequestBoxIronRdpError GetMultitransportRequest(ActiveStageOutput* self); + /// + /// Connection quality signals from the server's auto-detect mechanism. + /// Returns RTT and bandwidth measurements for health monitoring. + /// These values will feed into FramePacingFeedback when the + /// library-level health observer traits from #1158 land. + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_get_autodetect_network_characteristics", ExactSpelling = true)] + public static unsafe extern SessionFfiResultNetworkCharacteristicsBoxIronRdpError GetAutodetectNetworkCharacteristics(ActiveStageOutput* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_destroy", ExactSpelling = true)] public static unsafe extern void Destroy(ActiveStageOutput* self); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutputType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutputType.cs index 28d7a234c9..bc500012d6 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutputType.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutputType.cs @@ -22,4 +22,10 @@ public enum ActiveStageOutputType Terminate = 6, DeactivateAll = 7, MultitransportRequest = 8, + /// + /// Auto-detect network characteristics from server. + /// Use `get_autodetect_network_characteristics()` to retrieve + /// RTT and bandwidth values for connection quality monitoring. + /// + AutoDetect = 9, } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageType.cs index 569aa1258f..dd3a3569b8 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageType.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageType.cs @@ -14,9 +14,10 @@ namespace Devolutions.IronRdp.Raw; public enum ClipboardMessageType { SendInitiateCopy = 0, - SendFormatData = 1, - SendInitiatePaste = 2, - SendFileContentsRequest = 3, - SendFileContentsResponse = 4, - Error = 5, + SendInitiateFileCopy = 1, + SendFormatData = 2, + SendInitiatePaste = 3, + SendFileContentsRequest = 4, + SendFileContentsResponse = 5, + Error = 6, } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationSequence.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationSequence.cs index 6dc5956609..2652633570 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationSequence.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationSequence.cs @@ -28,6 +28,12 @@ public partial struct ConnectionActivationSequence [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationSequence_step_no_input", ExactSpelling = true)] public static unsafe extern ConnectorActivationFfiResultBoxWrittenBoxIronRdpError StepNoInput(ConnectionActivationSequence* self, WriteBuf* buf); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationSequence_get_io_channel_id", ExactSpelling = true)] + public static unsafe extern ushort GetIoChannelId(ConnectionActivationSequence* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationSequence_get_user_channel_id", ExactSpelling = true)] + public static unsafe extern ushort GetUserChannelId(ConnectionActivationSequence* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationSequence_destroy", ExactSpelling = true)] public static unsafe extern void Destroy(ConnectionActivationSequence* self); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationState.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationState.cs index 65ae1c5bf2..dc4d9fe299 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationState.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationState.cs @@ -19,9 +19,6 @@ public partial struct ConnectionActivationState [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationState_get_type", ExactSpelling = true)] public static unsafe extern ConnectionActivationStateType GetType(ConnectionActivationState* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationState_get_capabilities_exchange", ExactSpelling = true)] - public static unsafe extern ConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError GetCapabilitiesExchange(ConnectionActivationState* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationState_get_connection_finalization", ExactSpelling = true)] public static unsafe extern ConnectorActivationFfiResultBoxConnectionActivationStateConnectionFinalizationBoxIronRdpError GetConnectionFinalization(ConnectionActivationState* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateCapabilitiesExchange.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateCapabilitiesExchange.cs deleted file mode 100644 index 3214f0e3e3..0000000000 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateCapabilitiesExchange.cs +++ /dev/null @@ -1,27 +0,0 @@ -// by Diplomat - -#pragma warning disable 0105 -using System; -using System.Runtime.InteropServices; - -using Devolutions.IronRdp.Diplomat; -#pragma warning restore 0105 - -namespace Devolutions.IronRdp.Raw; - -#nullable enable - -[StructLayout(LayoutKind.Sequential)] -public partial struct ConnectionActivationStateCapabilitiesExchange -{ - private const string NativeLib = "DevolutionsIronRdp"; - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateCapabilitiesExchange_get_io_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetIoChannelId(ConnectionActivationStateCapabilitiesExchange* self); - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateCapabilitiesExchange_get_user_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetUserChannelId(ConnectionActivationStateCapabilitiesExchange* self); - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateCapabilitiesExchange_destroy", ExactSpelling = true)] - public static unsafe extern void Destroy(ConnectionActivationStateCapabilitiesExchange* self); -} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateConnectionFinalization.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateConnectionFinalization.cs index 7289659086..f868284d44 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateConnectionFinalization.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateConnectionFinalization.cs @@ -16,12 +16,6 @@ public partial struct ConnectionActivationStateConnectionFinalization { private const string NativeLib = "DevolutionsIronRdp"; - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateConnectionFinalization_get_io_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetIoChannelId(ConnectionActivationStateConnectionFinalization* self); - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateConnectionFinalization_get_user_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetUserChannelId(ConnectionActivationStateConnectionFinalization* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateConnectionFinalization_get_desktop_size", ExactSpelling = true)] public static unsafe extern DesktopSize* GetDesktopSize(ConnectionActivationStateConnectionFinalization* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateFinalized.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateFinalized.cs index 98d2c1baf4..f0977a75d0 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateFinalized.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateFinalized.cs @@ -16,12 +16,6 @@ public partial struct ConnectionActivationStateFinalized { private const string NativeLib = "DevolutionsIronRdp"; - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateFinalized_get_io_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetIoChannelId(ConnectionActivationStateFinalized* self); - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateFinalized_get_user_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetUserChannelId(ConnectionActivationStateFinalized* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateFinalized_get_share_id", ExactSpelling = true)] public static unsafe extern uint GetShareId(ConnectionActivationStateFinalized* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError.cs deleted file mode 100644 index 1c3bda1f54..0000000000 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError.cs +++ /dev/null @@ -1,46 +0,0 @@ -// by Diplomat - -#pragma warning disable 0105 -using System; -using System.Runtime.InteropServices; - -using Devolutions.IronRdp.Diplomat; -#pragma warning restore 0105 - -namespace Devolutions.IronRdp.Raw; - -#nullable enable - -[StructLayout(LayoutKind.Sequential)] -public partial struct ConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError -{ - [StructLayout(LayoutKind.Explicit)] - private unsafe struct InnerUnion - { - [FieldOffset(0)] - internal ConnectionActivationStateCapabilitiesExchange* ok; - [FieldOffset(0)] - internal IronRdpError* err; - } - - private InnerUnion _inner; - - [MarshalAs(UnmanagedType.U1)] - public bool isOk; - - public unsafe ConnectionActivationStateCapabilitiesExchange* Ok - { - get - { - return _inner.ok; - } - } - - public unsafe IronRdpError* Err - { - get - { - return _inner.err; - } - } -} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawNetworkCharacteristics.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawNetworkCharacteristics.cs new file mode 100644 index 0000000000..761e1552e4 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawNetworkCharacteristics.cs @@ -0,0 +1,44 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +/// +/// Connection quality measurements from server auto-detect (MS-RDPBCGR 2.2.14). +/// +[StructLayout(LayoutKind.Sequential)] +public partial struct NetworkCharacteristics +{ + private const string NativeLib = "DevolutionsIronRdp"; + + /// + /// Lowest detected round-trip time in milliseconds. + /// Only valid when `has_base_rtt` is true. + /// + public uint base_rtt_ms; + + [MarshalAs(UnmanagedType.U1)] + public bool has_base_rtt; + + /// + /// Current average round-trip time in milliseconds. + /// + public uint average_rtt_ms; + + /// + /// Estimated bandwidth in kilobits per second. + /// Only valid when `has_bandwidth` is true. + /// + public uint bandwidth_kbps; + + [MarshalAs(UnmanagedType.U1)] + public bool has_bandwidth; +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultNetworkCharacteristicsBoxIronRdpError.cs similarity index 79% rename from ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError.cs rename to ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultNetworkCharacteristicsBoxIronRdpError.cs index 0095580e4d..a6adfa93f2 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultNetworkCharacteristicsBoxIronRdpError.cs @@ -12,13 +12,13 @@ namespace Devolutions.IronRdp.Raw; #nullable enable [StructLayout(LayoutKind.Sequential)] -public partial struct SessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError +public partial struct SessionFfiResultNetworkCharacteristicsBoxIronRdpError { [StructLayout(LayoutKind.Explicit)] private unsafe struct InnerUnion { [FieldOffset(0)] - internal ConnectionActivationSequence* ok; + internal NetworkCharacteristics ok; [FieldOffset(0)] internal IronRdpError* err; } @@ -28,7 +28,7 @@ private unsafe struct InnerUnion [MarshalAs(UnmanagedType.U1)] public bool isOk; - public unsafe ConnectionActivationSequence* Ok + public unsafe NetworkCharacteristics Ok { get { diff --git a/ffi/src/connector/activation.rs b/ffi/src/connector/activation.rs index 716875e674..9a1b0c967e 100644 --- a/ffi/src/connector/activation.rs +++ b/ffi/src/connector/activation.rs @@ -16,7 +16,9 @@ pub mod ffi { impl ConnectionActivationSequence { pub fn get_state(&self) -> Box { - Box::new(ConnectionActivationState(self.0.connection_activation_state())) + Box::new(ConnectionActivationState { + state: self.0.connection_activation_state(), + }) } pub fn next_pdu_hint<'a>(&'a self) -> Result>>, Box> { @@ -33,10 +35,20 @@ pub mod ffi { let res = self.0.step_no_input(&mut buf.0).map(Written).map(Box::new)?; Ok(res) } + + pub fn get_io_channel_id(&self) -> u16 { + self.0.io_channel_id() + } + + pub fn get_user_channel_id(&self) -> u16 { + self.0.user_channel_id() + } } #[diplomat::opaque] - pub struct ConnectionActivationState(pub ironrdp::connector::connection_activation::ConnectionActivationState); + pub struct ConnectionActivationState { + pub state: ironrdp::connector::connection_activation::ConnectionActivationState, + } pub enum ConnectionActivationStateType { Consumed, @@ -47,13 +59,13 @@ pub mod ffi { impl ConnectionActivationState { pub fn get_type(&self) -> ConnectionActivationStateType { - match self.0 { + match self.state { ironrdp::connector::connection_activation::ConnectionActivationState::Consumed => { ConnectionActivationStateType::Consumed } - ironrdp::connector::connection_activation::ConnectionActivationState::CapabilitiesExchange { - .. - } => ConnectionActivationStateType::CapabilitiesExchange, + ironrdp::connector::connection_activation::ConnectionActivationState::CapabilitiesExchange => { + ConnectionActivationStateType::CapabilitiesExchange + } ironrdp::connector::connection_activation::ConnectionActivationState::ConnectionFinalization { .. } => ConnectionActivationStateType::ConnectionFinalization, @@ -63,36 +75,15 @@ pub mod ffi { } } - pub fn get_capabilities_exchange( - &self, - ) -> Result, Box> { - match &self.0 { - ironrdp::connector::connection_activation::ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - } => Ok(Box::new(ConnectionActivationStateCapabilitiesExchange { - io_channel_id: *io_channel_id, - user_channel_id: *user_channel_id, - })), - _ => Err(IncorrectEnumTypeError::on_variant("CapabilitiesExchange") - .of_enum("ConnectionActivationState") - .into()), - } - } - pub fn get_connection_finalization( &self, ) -> Result, Box> { - match self.0 { + match self.state { ironrdp::connector::connection_activation::ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, share_id: _, connection_finalization, } => Ok(Box::new(ConnectionActivationStateConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, connection_finalization, })), @@ -103,17 +94,13 @@ pub mod ffi { } pub fn get_finalized(&self) -> Result, Box> { - match &self.0 { + match &self.state { ironrdp::connector::connection_activation::ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, share_id, enable_server_pointer, pointer_software_rendering, } => Ok(Box::new(ConnectionActivationStateFinalized { - io_channel_id: *io_channel_id, - user_channel_id: *user_channel_id, share_id: *share_id, desktop_size: *desktop_size, enable_server_pointer: *enable_server_pointer, @@ -126,39 +113,13 @@ pub mod ffi { } } - #[diplomat::opaque] - pub struct ConnectionActivationStateCapabilitiesExchange { - pub io_channel_id: u16, - pub user_channel_id: u16, - } - - impl ConnectionActivationStateCapabilitiesExchange { - pub fn get_io_channel_id(&self) -> u16 { - self.io_channel_id - } - - pub fn get_user_channel_id(&self) -> u16 { - self.user_channel_id - } - } - #[diplomat::opaque] pub struct ConnectionActivationStateConnectionFinalization { - pub io_channel_id: u16, - pub user_channel_id: u16, pub desktop_size: ironrdp::connector::DesktopSize, pub connection_finalization: ironrdp::connector::ConnectionFinalizationSequence, } impl ConnectionActivationStateConnectionFinalization { - pub fn get_io_channel_id(&self) -> u16 { - self.io_channel_id - } - - pub fn get_user_channel_id(&self) -> u16 { - self.user_channel_id - } - pub fn get_desktop_size(&self) -> Box { Box::new(DesktopSize(self.desktop_size)) } @@ -166,8 +127,6 @@ pub mod ffi { #[diplomat::opaque] pub struct ConnectionActivationStateFinalized { - pub io_channel_id: u16, - pub user_channel_id: u16, pub share_id: u32, pub desktop_size: ironrdp::connector::DesktopSize, pub enable_server_pointer: bool, @@ -175,14 +134,6 @@ pub mod ffi { } impl ConnectionActivationStateFinalized { - pub fn get_io_channel_id(&self) -> u16 { - self.io_channel_id - } - - pub fn get_user_channel_id(&self) -> u16 { - self.user_channel_id - } - pub fn get_share_id(&self) -> u32 { self.share_id } diff --git a/ffi/src/session/mod.rs b/ffi/src/session/mod.rs index 37728024ed..ae6f6c7ebb 100644 --- a/ffi/src/session/mod.rs +++ b/ffi/src/session/mod.rs @@ -2,7 +2,6 @@ pub mod image; #[diplomat::bridge] pub mod ffi { - use super::image::ffi::DecodedImage; use crate::clipboard::message::ffi::{ClipboardFormatId, ClipboardFormatIterator, FormatDataResponse}; use crate::connector::activation::ffi::ConnectionActivationSequence; @@ -15,7 +14,10 @@ pub mod ffi { use crate::utils::ffi::{BytesSlice, Position, VecU8}; #[diplomat::opaque] - pub struct ActiveStage(pub ironrdp::session::ActiveStage); + pub struct ActiveStage( + pub ironrdp::session::ActiveStage, + pub ironrdp::connector::connection_activation::ConnectionActivationFactory, + ); #[diplomat::opaque] pub struct ActiveStageOutput(pub ironrdp::session::ActiveStageOutput); @@ -39,12 +41,35 @@ pub mod ffi { impl ActiveStage { pub fn new(connection_result: &mut ConnectionResult) -> Result, Box> { - Ok(Box::new(ActiveStage(ironrdp::session::ActiveStage::new( - connection_result - .0 - .take() - .ok_or_else(|| ValueConsumedError::for_item("connection_result"))?, - )))) + let connection_result = connection_result + .0 + .take() + .ok_or_else(|| ValueConsumedError::for_item("connection_result"))?; + + // Retain the factory to drive the Deactivation-Reactivation Sequence. + let activation_factory = connection_result.activation_factory; + + let stage = ironrdp::session::ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); + + Ok(Box::new(ActiveStage(stage, activation_factory))) + } + + /// Produces a fresh connection activation sequence to drive the Deactivation-Reactivation + /// Sequence. + /// + /// Call this upon receiving a [`ActiveStageOutputType::DeactivateAll`] output, drive the + /// returned sequence until it is finalized, then discard it. + pub fn create_connection_activation(&self) -> Box { + Box::new(ConnectionActivationSequence(Box::new(self.1.create()))) } pub fn process( @@ -213,7 +238,7 @@ pub mod ffi { ironrdp::session::ActiveStageOutput::PointerPosition { .. } => ActiveStageOutputType::PointerPosition, ironrdp::session::ActiveStageOutput::PointerBitmap { .. } => ActiveStageOutputType::PointerBitmap, ironrdp::session::ActiveStageOutput::Terminate { .. } => ActiveStageOutputType::Terminate, - ironrdp::session::ActiveStageOutput::DeactivateAll { .. } => ActiveStageOutputType::DeactivateAll, + ironrdp::session::ActiveStageOutput::DeactivateAll => ActiveStageOutputType::DeactivateAll, ironrdp::session::ActiveStageOutput::MultitransportRequest { .. } => { ActiveStageOutputType::MultitransportRequest } @@ -272,18 +297,6 @@ pub mod ffi { .map(Box::new) } - pub fn get_deactivate_all(&self) -> Result, Box> { - match &self.0 { - ironrdp::session::ActiveStageOutput::DeactivateAll(cas) => { - Ok(ConnectionActivationSequence(cas.clone())) - } - _ => Err(IncorrectEnumTypeError::on_variant("DeactivateAll") - .of_enum("ActiveStageOutput") - .into()), - } - .map(Box::new) - } - /// Returns the multitransport request ID and requested protocol. /// /// The security cookie is intentionally not exposed — it is sensitive From 437660fc595d14e09b474c71428f9a630484b6d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Jul 2026 05:06:37 -0400 Subject: [PATCH 317/325] ci: publish CLI binary release assets (#1437) --- .github/workflows/release-binaries.yml | 154 +++++++++++++++++++++++++ .github/workflows/release-crates.yml | 2 + README.md | 36 ++++++ release-plz.toml | 5 + 4 files changed, 197 insertions(+) create mode 100644 .github/workflows/release-binaries.yml diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 0000000000..e58278d587 --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -0,0 +1,154 @@ +name: Release binaries + +on: + release: + types: [published] + +permissions: + contents: write + +env: + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + RUSTUP_MAX_RETRIES: 10 + RUST_BACKTRACE: short + CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse + +jobs: + select-package: + name: Select package + runs-on: ubuntu-latest + outputs: + package: ${{ steps.select.outputs.package }} + version: ${{ steps.select.outputs.version }} + + steps: + - name: Select released CLI + id: select + env: + TAG_NAME: ${{ github.event.release.tag_name }} + run: | + case "$TAG_NAME" in + ironrdp-agent-v*) + package=ironrdp-agent + ;; + ironrdp-viewer-v*) + package=ironrdp-viewer + ;; + *) + exit 0 + ;; + esac + + echo "package=$package" >> "$GITHUB_OUTPUT" + echo "version=${TAG_NAME#"$package-v"}" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ needs.select-package.outputs.package }} [${{ matrix.target }}] + needs: select-package + if: ${{ needs.select-package.outputs.package != '' }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2022 + target: x86_64-pc-windows-msvc + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + - runner: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - runner: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + - runner: macos-15-intel + target: x86_64-apple-darwin + macos_deployment_target: '10.13' + - runner: macos-14 + target: aarch64-apple-darwin + macos_deployment_target: '11.0' + + steps: + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + run: | + sudo apt-get update -qq + sudo apt-get -y install libasound2-dev + + - name: Install NASM + if: ${{ runner.os == 'Windows' }} + run: | + choco install nasm + $Env:PATH += ";$Env:ProgramFiles\NASM" + echo "PATH=$Env:PATH" >> $Env:GITHUB_ENV + shell: pwsh + + - name: Rust cache + uses: Swatinem/rust-cache@v2.7.3 + + - name: Build release binary + shell: pwsh + run: | + if ($env:RUNNER_OS -eq 'Windows') { + $env:RUSTFLAGS = '-C target-feature=+crt-static' + } + + if ($env:RUNNER_OS -eq 'macOS') { + $env:MACOSX_DEPLOYMENT_TARGET = '${{ matrix.macos_deployment_target }}' + } + + cargo build --locked --release --package '${{ needs.select-package.outputs.package }}' + + - name: Package binary + shell: pwsh + env: + PACKAGE: ${{ needs.select-package.outputs.package }} + VERSION: ${{ needs.select-package.outputs.version }} + TARGET: ${{ matrix.target }} + run: | + $extension = if ($env:RUNNER_OS -eq 'Windows') { '.exe' } else { '' } + $binary = Join-Path (Join-Path 'target' 'release') "$env:PACKAGE$extension" + $assetName = "$env:PACKAGE-$env:VERSION-$env:TARGET.tar.gz" + $assetDirectory = 'release-assets' + + New-Item -ItemType Directory -Force -Path $assetDirectory | Out-Null + Copy-Item $binary (Join-Path $assetDirectory "$env:PACKAGE$extension") + + Push-Location $assetDirectory + tar -czf $assetName "$env:PACKAGE$extension" + $hash = (Get-FileHash -Algorithm SHA256 $assetName).Hash.ToLowerInvariant() + "$hash $assetName" | Set-Content -NoNewline -Encoding ascii "$assetName.sha256" + Remove-Item "$env:PACKAGE$extension" + Pop-Location + + - name: Upload release asset + uses: actions/upload-artifact@v7 + with: + name: release-assets-${{ matrix.target }} + path: release-assets/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Upload release assets + needs: [select-package, build] + if: ${{ always() && needs.select-package.outputs.package != '' && needs.build.result == 'success' }} + runs-on: ubuntu-latest + + steps: + - name: Download release assets + uses: actions/download-artifact@v8 + with: + pattern: release-assets-* + path: release-assets + merge-multiple: true + + - name: Upload release assets + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG_NAME" release-assets/* --clobber diff --git a/.github/workflows/release-crates.yml b/.github/workflows/release-crates.yml index 327c3eab5b..fbda6c8763 100644 --- a/.github/workflows/release-crates.yml +++ b/.github/workflows/release-crates.yml @@ -79,3 +79,5 @@ jobs: with: command: release registry-token: ${{ steps.auth.outputs.token }} + # Ensure the published GitHub Release triggers release-binaries.yml. + github-token: ${{ secrets.DEVOLUTIONSBOT_WRITE_TOKEN }} diff --git a/README.md b/README.md index 3bc5848515..a74a826011 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,42 @@ Alternatively, you may change a few group policies using `gpedit.msc`: 5. Reboot. +## Binary releases + +Standalone archives are attached to GitHub Releases for the executable packages: + +- [`ironrdp-agent`](./crates/ironrdp-agent) provides the agentic, daemon-backed CLI. +- [`ironrdp-viewer`](./crates/ironrdp-viewer) provides the windowed RDP client CLI. + +Each release provides one `.tar.gz` archive and a SHA-256 sidecar for these native target triples: + +| Platform | Target triple | +| --- | --- | +| Windows x64 | `x86_64-pc-windows-msvc` | +| Windows ARM64 | `aarch64-pc-windows-msvc` | +| Linux x64 | `x86_64-unknown-linux-gnu` | +| Linux ARM64 | `aarch64-unknown-linux-gnu` | +| macOS x64 | `x86_64-apple-darwin` | +| macOS ARM64 | `aarch64-apple-darwin` | + +Linux archives use an Ubuntu 22.04 build baseline and require glibc 2.35 or later. macOS archives +target macOS 10.13 or later on Intel and macOS 11.0 or later on Apple Silicon. + +For example, download and extract the Linux x64 agent from its release: + +```shell +VERSION= +ASSET="ironrdp-agent-${VERSION}-x86_64-unknown-linux-gnu.tar.gz" +curl -fLO "https://github.com/Devolutions/IronRDP/releases/download/ironrdp-agent-v${VERSION}/${ASSET}" +curl -fLO "https://github.com/Devolutions/IronRDP/releases/download/ironrdp-agent-v${VERSION}/${ASSET}.sha256" +sha256sum --check "${ASSET}.sha256" +tar -xzf "${ASSET}" +``` + +Replace `ironrdp-agent` with `ironrdp-viewer` to download the windowed client from its corresponding +package release. Windows archives contain an `.exe`; all other archives contain the executable without +an extension. + ## Rust version (MSRV) IronRDP libraries follow a conservative Minimum Supported Rust Version (MSRV) policy. diff --git a/release-plz.toml b/release-plz.toml index c5bbd12b09..080b4d51a3 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -8,6 +8,11 @@ changelog_config = "cliff.toml" release_commits = "^(feat|docs|fix|build|perf)" # Flagship crate for which we push a GitHub release. +[[package]] +name = "ironrdp-agent" +git_release_enable = true +publish = false # TODO: enable publishing when ready. + [[package]] name = "ironrdp-viewer" git_release_enable = true From 8a1fd0118e0bac214c9050b6ca6b36a040046dd3 Mon Sep 17 00:00:00 2001 From: Greg Lamberson Date: Fri, 10 Jul 2026 07:55:47 -0500 Subject: [PATCH 318/325] fix(pdu)!: send NetworkAutoDetect over the MCS message channel (#1348) Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by moving it off the I/O channel slow-path Share Data PDUs and onto the MCS message channel with the required Basic Security Header (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with mstsc/xfreerdp behavior and enables both connect-time and continuous auto-detection to actually function. --- crates/ironrdp-client/src/rdp.rs | 1 + crates/ironrdp-connector/src/connection.rs | 192 +++++++++++++++--- crates/ironrdp-pdu/src/rdp/autodetect.rs | 162 +++++++++++++++ crates/ironrdp-pdu/src/rdp/headers.rs | 28 --- crates/ironrdp-server/src/server.rs | 112 ++++++---- crates/ironrdp-session/src/active_stage.rs | 10 +- crates/ironrdp-session/src/x224/mod.rs | 75 ++++--- .../tests/connector/autodetect.rs | 172 ++++++++++++++++ .../tests/connector/mod.rs | 1 + crates/ironrdp-testsuite-core/tests/main.rs | 1 + .../tests/session/autodetect.rs | 74 ++++--- crates/ironrdp-testsuite-extra/tests/e2e.rs | 1 + crates/ironrdp-web/src/session.rs | 1 + crates/ironrdp/examples/screenshot.rs | 1 + ffi/src/session/mod.rs | 1 + 15 files changed, 662 insertions(+), 170 deletions(-) create mode 100644 crates/ironrdp-testsuite-core/tests/connector/autodetect.rs create mode 100644 crates/ironrdp-testsuite-core/tests/connector/mod.rs diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index d9e8bc70f9..0d44cb7ee0 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -740,6 +740,7 @@ async fn active_session( static_channels: connection_result.static_channels, user_channel_id: connection_result.user_channel_id, io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, share_id: connection_result.share_id, compression_type: connection_result.compression_type, enable_server_pointer: connection_result.enable_server_pointer, diff --git a/crates/ironrdp-connector/src/connection.rs b/crates/ironrdp-connector/src/connection.rs index c4ed3d5cfd..3c0dc582b6 100644 --- a/crates/ironrdp-connector/src/connection.rs +++ b/crates/ironrdp-connector/src/connection.rs @@ -23,6 +23,8 @@ use crate::{ pub struct ConnectionResult { pub io_channel_id: u16, pub user_channel_id: u16, + /// MCS channel ID of the message channel, when one was negotiated. + pub message_channel_id: Option, pub share_id: u32, pub static_channels: StaticChannelSet, pub desktop_size: DesktopSize, @@ -134,6 +136,8 @@ pub struct ClientConnector { /// The client address to be used in the Client Info PDU. pub client_addr: SocketAddr, pub static_channels: StaticChannelSet, + /// MCS message channel ID assigned by the server, once negotiated. + pub message_channel_id: Option, } impl ClientConnector { @@ -143,6 +147,7 @@ impl ClientConnector { state: ClientConnectorState::ConnectionInitiationSendRequest, client_addr, static_channels: StaticChannelSet::new(), + message_channel_id: None, } } @@ -208,6 +213,31 @@ impl ClientConnector { } } +fn advance_licensing_exchange( + mut license_exchange: LicenseExchangeSequence, + io_channel_id: u16, + user_channel_id: u16, + input: &[u8], + output: &mut WriteBuf, +) -> ConnectorResult<(Written, ClientConnectorState)> { + let written = license_exchange.step(input, output)?; + + let next_state = if license_exchange.state.is_terminal() { + ClientConnectorState::MultitransportBootstrapping { + io_channel_id, + user_channel_id, + } + } else { + ClientConnectorState::LicensingExchange { + io_channel_id, + user_channel_id, + license_exchange, + } + }; + + Ok((written, next_state)) +} + impl Sequence for ClientConnector { fn next_pdu_hint(&self) -> Option<&dyn PduHint> { match &self.state { @@ -220,7 +250,20 @@ impl Sequence for ClientConnector { ClientConnectorState::BasicSettingsExchangeWaitResponse { .. } => Some(&ironrdp_pdu::X224_HINT), ClientConnectorState::ChannelConnection { channel_connection, .. } => channel_connection.next_pdu_hint(), ClientConnectorState::SecureSettingsExchange { .. } => None, - ClientConnectorState::ConnectTimeAutoDetection { .. } => None, + ClientConnectorState::ConnectTimeAutoDetection { .. } => { + // Wait for input only when a message channel was negotiated, so + // we can receive connect-time auto-detect requests there. With a + // message channel the server always sends a PDU next in this phase + // (a connect-time Auto-Detect Request on the message channel, or + // the first licensing PDU on the I/O channel), so waiting here + // cannot stall. Without one, this state reads nothing and + // transitions straight to licensing. + if self.message_channel_id.is_some() { + Some(&ironrdp_pdu::X224_HINT) + } else { + None + } + } ClientConnectorState::LicensingExchange { license_exchange, .. } => license_exchange.next_pdu_hint(), ClientConnectorState::MultitransportBootstrapping { .. } => None, ClientConnectorState::CapabilitiesExchange { @@ -390,9 +433,10 @@ impl Sequence for ClientConnector { return Err(general_err!("can't satisfy server security settings")); } - if server_gcc_blocks.message_channel.is_some() { - warn!("Unexpected ServerMessageChannelData GCC block (not supported)"); - } + self.message_channel_id = server_gcc_blocks + .message_channel + .as_ref() + .map(|data| data.mcs_message_channel_id); if server_gcc_blocks.multi_transport_channel.is_some() { warn!("Unexpected MultiTransportChannelData GCC block (not supported)"); @@ -426,7 +470,9 @@ impl Sequence for ClientConnector { channel_connection: if skip_channel_join { ChannelConnectionSequence::skip_channel_join() } else { - ChannelConnectionSequence::new(io_channel_id, static_channel_ids) + let mut join_channel_ids = static_channel_ids; + join_channel_ids.extend(self.message_channel_id); + ChannelConnectionSequence::new(io_channel_id, join_channel_ids) }, }, ) @@ -493,12 +539,59 @@ impl Sequence for ClientConnector { ClientConnectorState::ConnectTimeAutoDetection { io_channel_id, user_channel_id, - } => ( - Written::Nothing, - ClientConnectorState::LicensingExchange { - io_channel_id, - user_channel_id, - license_exchange: LicenseExchangeSequence::new( + } => { + // The server may run Optional Connect-Time Auto-Detection on the + // message channel before licensing ([MS-RDPBCGR] 1.3.8). When a + // message channel was negotiated we wait for a PDU here and demux + // by MCS channel: a PDU on the message channel is never a licensing + // PDU, so it must not be handed to the licensing sequence. An + // auto-detect request is answered and we keep listening; any other + // message-channel PDU is not ours to act on in this phase and is + // ignored. The first PDU that is not on the message channel (the + // licensing PDU on the I/O channel) ends the phase. Without a + // message channel nothing is read and we go straight to licensing, + // as before. + // Decode the inbound PDU once and demux on the MCS channel. + let message_channel_pdu = self.message_channel_id.and_then(|message_channel_id| { + let mcs = decode::>>(input).ok()?; + match mcs.0 { + mcs::McsMessage::SendDataIndication(data) if data.channel_id == message_channel_id => { + Some((message_channel_id, data)) + } + _ => None, + } + }); + + if let Some((message_channel_id, data)) = message_channel_pdu { + if let Ok(autodetect) = decode::(&data.user_data) { + let written = respond_to_connect_time_autodetect( + autodetect.request, + message_channel_id, + user_channel_id, + output, + )?; + ( + written, + ClientConnectorState::ConnectTimeAutoDetection { + io_channel_id, + user_channel_id, + }, + ) + } else { + // A message-channel PDU we do not handle in this phase (per the + // canonical sequence multitransport bootstrap is Phase 8 and + // heartbeat is post-connection, both after licensing). Ignore it + // and keep listening rather than decoding it as a licensing PDU. + ( + Written::Nothing, + ClientConnectorState::ConnectTimeAutoDetection { + io_channel_id, + user_channel_id, + }, + ) + } + } else { + let license_exchange = LicenseExchangeSequence::new( io_channel_id, self.config.credentials.username().unwrap_or("").to_owned(), self.config.domain.clone(), @@ -507,9 +600,27 @@ impl Sequence for ClientConnector { .license_cache .clone() .unwrap_or_else(|| Arc::new(NoopLicenseCache)), - ), - }, - ), + ); + // If a PDU was read (message channel present) it is the first + // licensing PDU; advance the licensing sequence with it now, + // through the same helper the LicensingExchange state uses, so + // the terminal-state transition lives in one place. Otherwise + // nothing was read and the licensing sequence runs from its + // first step when the next PDU arrives. + if self.message_channel_id.is_some() { + advance_licensing_exchange(license_exchange, io_channel_id, user_channel_id, input, output)? + } else { + ( + Written::Nothing, + ClientConnectorState::LicensingExchange { + io_channel_id, + user_channel_id, + license_exchange, + }, + ) + } + } + } //== Licensing ==// // Server is sending information regarding licensing. @@ -517,26 +628,11 @@ impl Sequence for ClientConnector { ClientConnectorState::LicensingExchange { io_channel_id, user_channel_id, - mut license_exchange, + license_exchange, } => { debug!("Licensing Exchange"); - let written = license_exchange.step(input, output)?; - - let next_state = if license_exchange.state.is_terminal() { - ClientConnectorState::MultitransportBootstrapping { - io_channel_id, - user_channel_id, - } - } else { - ClientConnectorState::LicensingExchange { - io_channel_id, - user_channel_id, - license_exchange, - } - }; - - (written, next_state) + advance_licensing_exchange(license_exchange, io_channel_id, user_channel_id, input, output)? } //== Optional Multitransport Bootstrapping ==// @@ -599,6 +695,7 @@ impl Sequence for ClientConnector { result: ConnectionResult { io_channel_id: connection_activation.io_channel_id(), user_channel_id: connection_activation.user_channel_id(), + message_channel_id: self.message_channel_id, share_id, static_channels: mem::take(&mut self.static_channels), desktop_size, @@ -649,6 +746,32 @@ pub fn encode_send_data_request( Ok(written) } +fn respond_to_connect_time_autodetect( + request: rdp::autodetect::AutoDetectRequest, + message_channel_id: u16, + user_channel_id: u16, + output: &mut WriteBuf, +) -> ConnectorResult { + use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse, AutoDetectRspPdu}; + + match request { + AutoDetectRequest::RttRequest { sequence_number, .. } => { + let response = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number }); + let written = encode_send_data_request(user_channel_id, message_channel_id, &response, output)?; + Written::from_size(written) + } + // Only RTT is answered at connect time. A connect-time Bandwidth Measure + // Stop ([MS-RDPBCGR] 2.2.14.1.4) is defined to warrant a Bandwidth Measure + // Results reply, and the Network Characteristics Result is informational. + // We deliberately send neither: connect-time auto-detect is informational + // and the server proceeds to licensing whether or not it receives them, so + // skipping them does not stall the sequence. Full connect-time bandwidth + // measurement (replying to Bandwidth Measure Stop with Bandwidth Measure + // Results) is left for a follow-up. + _ => Ok(Written::Nothing), + } +} + #[expect(single_use_lifetimes)] // anonymous lifetimes in `impl Trait` are unstable fn create_gcc_blocks<'a>( config: &Config, @@ -713,6 +836,7 @@ fn create_gcc_blocks<'a>( let mut early_capability_flags = ClientEarlyCapabilityFlags::VALID_CONNECTION_TYPE | ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU | ClientEarlyCapabilityFlags::STRONG_ASYMMETRIC_KEYS + | ClientEarlyCapabilityFlags::SUPPORT_NET_CHAR_AUTODETECT | ClientEarlyCapabilityFlags::SUPPORT_SKIP_CHANNELJOIN; // TODO(#136): support for ClientEarlyCapabilityFlags::SUPPORT_STATUS_INFO_PDU @@ -753,8 +877,10 @@ fn create_gcc_blocks<'a>( // TODO(#139): support for Some(ClientClusterData { flags: RedirectionFlags::REDIRECTION_SUPPORTED, redirection_version: RedirectionVersion::V4, redirected_session_id: 0, }), cluster: None, monitor: None, - // TODO(#140): support for Client Message Channel Data (https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/f50e791c-de03-4b25-b17e-e914c9020bc3) - message_channel: None, + // Request the MCS message channel, which carries network auto-detect + // ([MS-RDPBCGR] 2.2.14) and the multitransport / heartbeat PDUs. The + // server assigns its ID in Server Message Channel Data. + message_channel: Some(gcc::ClientMessageChannelData), multi_transport_channel: config .multitransport_flags .map(|flags| gcc::MultiTransportChannelData { flags }), diff --git a/crates/ironrdp-pdu/src/rdp/autodetect.rs b/crates/ironrdp-pdu/src/rdp/autodetect.rs index fc49c54c68..630ab00edb 100644 --- a/crates/ironrdp-pdu/src/rdp/autodetect.rs +++ b/crates/ironrdp-pdu/src/rdp/autodetect.rs @@ -15,6 +15,8 @@ use ironrdp_core::{ Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, }; +use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; + // ============================================================================ // Constants // ============================================================================ @@ -676,10 +678,170 @@ impl<'de> Decode<'de> for AutoDetectResponse { } } +// ============================================================================ +// MCS message channel framing +// ============================================================================ +// +// Auto-detect is not a Share Data PDU. Per [MS-RDPBCGR] 2.2.14.3 / 2.2.14.4 it +// rides the MCS message channel framed by a Basic Security Header whose +// SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP flag identifies it, the same dispatch +// mechanism used by multitransport (see `rdp::multitransport`). + +/// Server Auto-Detect Request PDU ([MS-RDPBCGR] 2.2.14.3). +/// +/// Wraps an [`AutoDetectRequest`] with the `SEC_AUTODETECT_REQ` security header. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct AutoDetectReqPdu { + pub security_header: BasicSecurityHeader, + pub request: AutoDetectRequest, +} + +impl AutoDetectReqPdu { + const NAME: &'static str = "AutoDetectReqPdu"; + + /// Wrap a request with the `SEC_AUTODETECT_REQ` security header. + pub fn new(request: AutoDetectRequest) -> Self { + Self { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::AUTODETECT_REQ, + }, + request, + } + } +} + +impl Encode for AutoDetectReqPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.security_header.encode(dst)?; + self.request.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + BasicSecurityHeader::FIXED_PART_SIZE + self.request.size() + } +} + +impl<'de> Decode<'de> for AutoDetectReqPdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + let security_header = BasicSecurityHeader::decode(src)?; + + if !security_header.flags.contains(BasicSecurityHeaderFlags::AUTODETECT_REQ) { + return Err(invalid_field_err!("securityHeader", "expected SEC_AUTODETECT_REQ flag")); + } + + let request = AutoDetectRequest::decode(src)?; + + Ok(Self { + security_header, + request, + }) + } +} + +/// Client Auto-Detect Response PDU ([MS-RDPBCGR] 2.2.14.4). +/// +/// Wraps an [`AutoDetectResponse`] with the `SEC_AUTODETECT_RSP` security header. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct AutoDetectRspPdu { + pub security_header: BasicSecurityHeader, + pub response: AutoDetectResponse, +} + +impl AutoDetectRspPdu { + const NAME: &'static str = "AutoDetectRspPdu"; + + /// Wrap a response with the `SEC_AUTODETECT_RSP` security header. + pub fn new(response: AutoDetectResponse) -> Self { + Self { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::AUTODETECT_RSP, + }, + response, + } + } +} + +impl Encode for AutoDetectRspPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.security_header.encode(dst)?; + self.response.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + BasicSecurityHeader::FIXED_PART_SIZE + self.response.size() + } +} + +impl<'de> Decode<'de> for AutoDetectRspPdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + let security_header = BasicSecurityHeader::decode(src)?; + + if !security_header.flags.contains(BasicSecurityHeaderFlags::AUTODETECT_RSP) { + return Err(invalid_field_err!("securityHeader", "expected SEC_AUTODETECT_RSP flag")); + } + + let response = AutoDetectResponse::decode(src)?; + + Ok(Self { + security_header, + response, + }) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn req_pdu_round_trip() { + let original = AutoDetectReqPdu::new(AutoDetectRequest::RttRequest { + sequence_number: 7, + request_type: RTT_REQUEST_CONTINUOUS, + }); + assert_eq!(original.security_header.flags, BasicSecurityHeaderFlags::AUTODETECT_REQ); + + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded = ironrdp_core::decode::(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn rsp_pdu_round_trip() { + let original = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number: 7 }); + assert_eq!(original.security_header.flags, BasicSecurityHeaderFlags::AUTODETECT_RSP); + + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded = ironrdp_core::decode::(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn req_pdu_rejects_response_flag() { + // A response-flagged frame must not decode as a request PDU. + let rsp = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number: 1 }); + let encoded = ironrdp_core::encode_vec(&rsp).unwrap(); + assert!(ironrdp_core::decode::(&encoded).is_err()); + } + // ======================================================================== // Request encoding/decoding tests // ======================================================================== diff --git a/crates/ironrdp-pdu/src/rdp/headers.rs b/crates/ironrdp-pdu/src/rdp/headers.rs index 31bf39f30e..223097f799 100644 --- a/crates/ironrdp-pdu/src/rdp/headers.rs +++ b/crates/ironrdp-pdu/src/rdp/headers.rs @@ -10,7 +10,6 @@ use num_traits::FromPrimitive as _; use crate::codecs::rfx::FrameAcknowledgePdu; use crate::input::InputEventPdu; use crate::mcs::SendDataIndicationCtx; -use crate::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; use crate::rdp::capability_sets::{ClientConfirmActive, ServerDemandActive}; use crate::rdp::client_info; use crate::rdp::finalization_messages::{ControlPdu, FontPdu, MonitorLayoutPdu, SynchronizePdu}; @@ -487,10 +486,6 @@ pub enum ShareDataPdu { DrawGdiPusErrorPdu(Vec), ArcStatusPdu(Vec), StatusInfoPdu(Vec), - /// Auto-Detect Request (server to client) - AutoDetectReq(AutoDetectRequest), - /// Auto-Detect Response (client to server) - AutoDetectRsp(AutoDetectResponse), } impl ShareDataPdu { @@ -523,8 +518,6 @@ impl ShareDataPdu { ShareDataPdu::DrawGdiPusErrorPdu(_) => "Draw GDI PUS Error PDU", ShareDataPdu::ArcStatusPdu(_) => "Arc Status PDU", ShareDataPdu::StatusInfoPdu(_) => "Status Info PDU", - ShareDataPdu::AutoDetectReq(_) => "Auto-Detect Request PDU", - ShareDataPdu::AutoDetectRsp(_) => "Auto-Detect Response PDU", } } @@ -555,7 +548,6 @@ impl ShareDataPdu { ShareDataPdu::DrawGdiPusErrorPdu(_) => ShareDataPduType::DrawGdiPusErrorPdu, ShareDataPdu::ArcStatusPdu(_) => ShareDataPduType::ArcStatusPdu, ShareDataPdu::StatusInfoPdu(_) => ShareDataPduType::StatusInfoPdu, - ShareDataPdu::AutoDetectReq(_) | ShareDataPdu::AutoDetectRsp(_) => ShareDataPduType::AutoDetect, } } @@ -596,15 +588,6 @@ impl ShareDataPdu { ShareDataPduType::DrawGdiPusErrorPdu => Ok(ShareDataPdu::DrawGdiPusErrorPdu(src.remaining().to_vec())), ShareDataPduType::ArcStatusPdu => Ok(ShareDataPdu::ArcStatusPdu(src.remaining().to_vec())), ShareDataPduType::StatusInfoPdu => Ok(ShareDataPdu::StatusInfoPdu(src.remaining().to_vec())), - ShareDataPduType::AutoDetect => { - ensure_size!(in: src, size: 2); - let type_id = src.remaining()[1]; - if type_id == crate::rdp::autodetect::TYPE_ID_AUTODETECT_REQUEST { - Ok(ShareDataPdu::AutoDetectReq(AutoDetectRequest::decode(src)?)) - } else { - Ok(ShareDataPdu::AutoDetectRsp(AutoDetectResponse::decode(src)?)) - } - } } } } @@ -623,8 +606,6 @@ impl Encode for ShareDataPdu { ShareDataPdu::ShutdownRequest | ShareDataPdu::ShutdownDenied => Ok(()), ShareDataPdu::SuppressOutput(pdu) => pdu.encode(dst), ShareDataPdu::RefreshRectangle(pdu) => pdu.encode(dst), - ShareDataPdu::AutoDetectReq(pdu) => pdu.encode(dst), - ShareDataPdu::AutoDetectRsp(pdu) => pdu.encode(dst), _ => Err(other_err!("Encoding not implemented")), } } @@ -658,8 +639,6 @@ impl Encode for ShareDataPdu { | ShareDataPdu::DrawGdiPusErrorPdu(buffer) | ShareDataPdu::ArcStatusPdu(buffer) | ShareDataPdu::StatusInfoPdu(buffer) => buffer.len(), - ShareDataPdu::AutoDetectReq(pdu) => pdu.size(), - ShareDataPdu::AutoDetectRsp(pdu) => pdu.size(), } } } @@ -757,13 +736,6 @@ pub enum ShareDataPduType { StatusInfoPdu = 0x36, MonitorLayoutPdu = 0x37, FrameAcknowledgePdu = 0x38, - /// Auto-Detect Request or Response ([MS-RDPBCGR 2.2.14]). - /// - /// The headerTypeId field within the PDU body discriminates direction: - /// 0x00 for server-to-client requests, 0x01 for client-to-server responses. - /// - /// [MS-RDPBCGR 2.2.14]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dc672839-4f4e-40b1-a71c-cd6a959baa38 - AutoDetect = 0x3b, } impl ShareDataPduType { diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index acdfe99dce..612a629f3d 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1013,6 +1013,7 @@ impl RdpServer { writer: &mut impl FramedWrite, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, ) -> Result { match action { Action::FastPath => { @@ -1022,7 +1023,7 @@ impl RdpServer { Action::X224 => { if self - .handle_x224(writer, io_channel_id, user_channel_id, &bytes) + .handle_x224(writer, io_channel_id, user_channel_id, message_channel_id, &bytes) .await .context("X224 input error")? { @@ -1076,8 +1077,8 @@ impl RdpServer { &mut self, events: &mut Vec, writer: &mut impl FramedWrite, - io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, ) -> Result { // Avoid wave messages queuing up and causing extra delay. When a // batch carries more than `WAVE_KEEP` waves, drop the OLDEST ones @@ -1202,14 +1203,13 @@ impl RdpServer { } }, ServerEvent::AutoDetectRttRequest => { - if let Some(ref mut ad) = self.autodetect { + // Auto-detect requests ride the MCS message channel + // ([MS-RDPBCGR] 2.2.14.3). With none negotiated (the client + // did not request it), there is nowhere to send them. + if let (Some(ad), Some(message_channel_id)) = (self.autodetect.as_mut(), message_channel_id) { ad.expire_stale_probes(crate::autodetect::RTT_PROBE_MAX_AGE); let request = ad.send_rtt_request(); - let data = encode_share_data_pdu( - rdp::headers::ShareDataPdu::AutoDetectReq(request), - io_channel_id, - user_channel_id, - )?; + let data = encode_autodetect_request(request, message_channel_id, user_channel_id)?; writer.write_all(&data).await?; } } @@ -1225,6 +1225,7 @@ impl RdpServer { writer: &mut Framed, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, mut encoder: UpdateEncoder, ) -> Result where @@ -1245,7 +1246,14 @@ impl RdpServer { let (action, bytes) = reader.read_pdu().await?; let mut this = this.lock().await; match this - .dispatch_pdu(action, bytes, &mut writer, io_channel_id, user_channel_id) + .dispatch_pdu( + action, + bytes, + &mut writer, + io_channel_id, + user_channel_id, + message_channel_id, + ) .await? { RunState::Continue => continue, @@ -1304,7 +1312,7 @@ impl RdpServer { } let mut this = this.lock().await; match this - .dispatch_server_events(&mut events, &mut event_writer, io_channel_id, user_channel_id) + .dispatch_server_events(&mut events, &mut event_writer, user_channel_id, message_channel_id) .await? { RunState::Continue => continue, @@ -1367,6 +1375,7 @@ impl RdpServer { writer, result.io_channel_id, result.user_channel_id, + result.message_channel_id, result.input_events, ) .await?; @@ -1475,7 +1484,14 @@ impl RdpServer { .context("failed to initialize update encoder")?; let state = self - .client_loop(reader, writer, result.io_channel_id, result.user_channel_id, encoder) + .client_loop( + reader, + writer, + result.io_channel_id, + result.user_channel_id, + result.message_channel_id, + encoder, + ) .await .context("client loop failure")?; @@ -1487,6 +1503,7 @@ impl RdpServer { writer: &mut impl FramedWrite, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, frames: Vec>, ) -> Result<()> { for frame in frames { @@ -1497,7 +1514,9 @@ impl RdpServer { } Ok(Action::X224) => { - let _ = self.handle_x224(writer, io_channel_id, user_channel_id, &frame).await; + let _ = self + .handle_x224(writer, io_channel_id, user_channel_id, message_channel_id, &frame) + .await; } // the frame here is always valid, because otherwise it would @@ -1557,17 +1576,6 @@ impl RdpServer { return Ok(true); } - rdp::headers::ShareDataPdu::AutoDetectRsp(response) => { - if let Some(ref mut ad) = self.autodetect { - if let Some(rtt_ms) = ad.handle_response(&response) { - self.autodetect_rtt.store(rtt_ms, Ordering::Relaxed); - debug!(rtt_ms, seq = response.sequence_number(), "RTT measured"); - } else { - trace!(seq = response.sequence_number(), "Unmatched auto-detect response"); - } - } - } - // Client requests the server stop or resume sending display // updates. mstsc sends `desktop_rect: None` on minimize and // `desktop_rect: Some(rect)` on refocus. Without honoring @@ -1610,11 +1618,33 @@ impl RdpServer { Ok(false) } + fn handle_message_channel_data(&mut self, data: SendDataRequest<'_>) { + // The MCS message channel currently carries only the auto-detect + // response. It is framed by a Basic Security Header (SEC_AUTODETECT_RSP), + // not a Share Control header. + match decode::(data.user_data.as_ref()) { + Ok(pdu) => { + if let Some(ref mut ad) = self.autodetect { + if let Some(rtt_ms) = ad.handle_response(&pdu.response) { + self.autodetect_rtt.store(rtt_ms, Ordering::Relaxed); + debug!(rtt_ms, seq = pdu.response.sequence_number(), "RTT measured"); + } else { + trace!(seq = pdu.response.sequence_number(), "Unmatched auto-detect response"); + } + } + } + Err(error) => { + warn!(error = format!("{error:#}"), "Unhandled MCS message channel PDU"); + } + } + } + async fn handle_x224( &mut self, writer: &mut impl FramedWrite, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, frame: &[u8], ) -> Result { let message = decode::>>(frame)?; @@ -1630,6 +1660,11 @@ impl RdpServer { return self.handle_io_channel_data(data).await; } + if message_channel_id == Some(data.channel_id) { + self.handle_message_channel_data(data); + return Ok(false); + } + if let Some(svc) = self.static_channels.get_by_channel_id_mut(data.channel_id) { let response_pdus = svc.process(&data.user_data)?; let response = server_encode_svc_messages(response_pdus, data.channel_id, user_channel_id)?; @@ -1728,32 +1763,23 @@ impl RdpServer { } } -/// Encode a server-initiated Share Data PDU for the IO channel. +/// Encode a server-initiated Auto-Detect Request PDU for the MCS message channel. /// -/// `share_id` is hard-coded to 0, matching the existing convention in -/// `deactivate_all()`. In practice, RDP clients do not validate `share_id` -/// on server-initiated PDUs, but a future refactor could thread the -/// negotiated value from the Demand Active exchange if needed. -fn encode_share_data_pdu( - share_data_pdu: rdp::headers::ShareDataPdu, - io_channel_id: u16, +/// The request is framed by a Basic Security Header (SEC_AUTODETECT_REQ) per +/// [MS-RDPBCGR] 2.2.14.3 and carried in an MCS Send Data Indication on the +/// negotiated message channel, not as a Share Data PDU on the I/O channel. +fn encode_autodetect_request( + request: rdp::autodetect::AutoDetectRequest, + message_channel_id: u16, user_channel_id: u16, ) -> Result> { - let header = rdp::headers::ShareDataHeader { - share_data_pdu, - stream_priority: rdp::headers::StreamPriority::Medium, - compression_flags: rdp::headers::CompressionFlags::empty(), - compression_type: rdp::client_info::CompressionType::K8, - }; - let pdu = rdp::headers::ShareControlHeader { - share_id: 0, - pdu_source: user_channel_id, - share_control_pdu: ShareControlPdu::Data(header), - }; + // Auto-detect rides the MCS message channel framed by a Basic Security + // Header (SEC_AUTODETECT_REQ), not a Share Control / Share Data header. + let pdu = rdp::autodetect::AutoDetectReqPdu::new(request); let user_data = encode_vec(&pdu)?.into(); let mcs_pdu = SendDataIndication { initiator_id: user_channel_id, - channel_id: io_channel_id, + channel_id: message_channel_id, user_data, }; Ok(encode_vec(&X224(mcs_pdu))?) diff --git a/crates/ironrdp-session/src/active_stage.rs b/crates/ironrdp-session/src/active_stage.rs index 1578015b68..f1b712eb25 100644 --- a/crates/ironrdp-session/src/active_stage.rs +++ b/crates/ironrdp-session/src/active_stage.rs @@ -44,6 +44,7 @@ pub struct ActiveStageBuilder { pub static_channels: StaticChannelSet, pub user_channel_id: u16, pub io_channel_id: u16, + pub message_channel_id: Option, pub share_id: u32, /// The bulk compression type that was negotiated, if any. pub compression_type: Option, @@ -59,13 +60,20 @@ impl ActiveStageBuilder { static_channels, user_channel_id, io_channel_id, + message_channel_id, share_id, compression_type, enable_server_pointer, pointer_software_rendering, } = self; - let x224_processor = x224::Processor::new(static_channels, user_channel_id, io_channel_id, share_id); + let x224_processor = x224::Processor::new( + static_channels, + user_channel_id, + io_channel_id, + message_channel_id, + share_id, + ); // Create bulk decompressor if compression was negotiated let bulk_decompressor = compression_type.and_then(|ct| { diff --git a/crates/ironrdp-session/src/x224/mod.rs b/crates/ironrdp-session/src/x224/mod.rs index a7271f987e..b2a6927aa2 100644 --- a/crates/ironrdp-session/src/x224/mod.rs +++ b/crates/ironrdp-session/src/x224/mod.rs @@ -1,7 +1,7 @@ -use ironrdp_core::WriteBuf; +use ironrdp_core::{WriteBuf, decode}; use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel}; use ironrdp_pdu::mcs::{DisconnectProviderUltimatum, DisconnectReason, McsMessage, SendDataIndicationCtx}; -use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; +use ironrdp_pdu::rdp::autodetect::{AutoDetectReqPdu, AutoDetectRequest, AutoDetectResponse, AutoDetectRspPdu}; use ironrdp_pdu::rdp::headers::ShareDataPdu; use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu; use ironrdp_pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, ServerSetErrorInfoPdu}; @@ -63,15 +63,23 @@ pub struct Processor { static_channels: StaticChannelSet, user_channel_id: u16, io_channel_id: u16, + message_channel_id: Option, share_id: u32, } impl Processor { - pub fn new(static_channels: StaticChannelSet, user_channel_id: u16, io_channel_id: u16, share_id: u32) -> Self { + pub fn new( + static_channels: StaticChannelSet, + user_channel_id: u16, + io_channel_id: u16, + message_channel_id: Option, + share_id: u32, + ) -> Self { Self { static_channels, user_channel_id, io_channel_id, + message_channel_id, share_id, } } @@ -124,6 +132,8 @@ impl Processor { if channel_id == self.io_channel_id { self.process_io_channel(data_ctx) + } else if self.message_channel_id == Some(channel_id) { + self.process_message_channel(data_ctx) } else if let Some(svc) = self.static_channels.get_by_channel_id_mut(channel_id) { let response_pdus = svc.process(data_ctx.user_data).map_err(SessionError::pdu)?; process_svc_messages(response_pdus, channel_id, data_ctx.initiator_id) @@ -185,28 +195,6 @@ impl Processor { )), ]) } - ShareDataPdu::AutoDetectReq(AutoDetectRequest::RttRequest { sequence_number, .. }) => { - let response = AutoDetectResponse::RttResponse { sequence_number }; - let mut frame = WriteBuf::new(); - ironrdp_pdu::rdp::headers::encode_share_data( - self.user_channel_id, - self.io_channel_id, - self.share_id, - ShareDataPdu::AutoDetectRsp(response), - &mut frame, - ) - .map_err(SessionError::encode)?; - debug!(sequence_number, "Responded to auto-detect RTT request"); - Ok(vec![ProcessorOutput::ResponseFrame(frame.into_inner())]) - } - ShareDataPdu::AutoDetectReq(req @ AutoDetectRequest::NetworkCharacteristicsResult { .. }) => { - debug!(?req, "Received network characteristics from server"); - Ok(vec![ProcessorOutput::AutoDetect(req)]) - } - ShareDataPdu::AutoDetectReq(_) => { - debug!(pdu = %ctx.pdu.as_short_name(), "Auto-detect request not yet implemented"); - Ok(Vec::new()) - } // TODO: slow-path payloads may be bulk-compressed when // ClientInfoFlags::COMPRESSION is negotiated. Decompression // should happen here before passing data downstream. Currently @@ -240,6 +228,43 @@ impl Processor { } } + /// Process an auto-detect request received on the MCS message channel. + /// + /// During continuous auto-detection ([MS-RDPBCGR] 2.2.14) the server sends + /// RTT (and bandwidth) requests on the message channel; the client answers + /// RTT requests and surfaces the final Network Characteristics Result. + fn process_message_channel(&self, data_ctx: SendDataIndicationCtx<'_>) -> SessionResult> { + let Some(message_channel_id) = self.message_channel_id else { + return Err(reason_err!("message channel", "no message channel negotiated")); + }; + + let req = decode::(data_ctx.user_data).map_err(SessionError::decode)?; + + match req.request { + AutoDetectRequest::RttRequest { sequence_number, .. } => { + let response = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number }); + let mut frame = WriteBuf::new(); + ironrdp_pdu::mcs::encode_send_data_request( + self.user_channel_id, + message_channel_id, + &response, + &mut frame, + ) + .map_err(SessionError::encode)?; + debug!(sequence_number, "Responded to auto-detect RTT request"); + Ok(vec![ProcessorOutput::ResponseFrame(frame.into_inner())]) + } + req @ AutoDetectRequest::NetworkCharacteristicsResult { .. } => { + debug!(?req, "Received network characteristics from server"); + Ok(vec![ProcessorOutput::AutoDetect(req)]) + } + req => { + debug!(?req, "Auto-detect request not yet implemented"); + Ok(Vec::new()) + } + } + } + /// Send a pdu on the static global channel. Typically used to send input events pub fn encode_static(&self, output: &mut WriteBuf, pdu: ShareDataPdu) -> SessionResult { let written = ironrdp_pdu::rdp::headers::encode_share_data( diff --git a/crates/ironrdp-testsuite-core/tests/connector/autodetect.rs b/crates/ironrdp-testsuite-core/tests/connector/autodetect.rs new file mode 100644 index 0000000000..dfddfe74de --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/connector/autodetect.rs @@ -0,0 +1,172 @@ +//! Connect-time auto-detection demux in the client connector. +//! +//! The continuous (session) auto-detect path is covered in +//! `tests/session/autodetect.rs`. These tests cover the connector's +//! `ConnectTimeAutoDetection` state, which demultiplexes the first PDU received +//! once a message channel has been negotiated: an Auto-Detect Request on the +//! message channel is answered, any other message-channel PDU is ignored, and a +//! PDU on the I/O channel is the first licensing PDU. + +use std::borrow::Cow; + +use ironrdp_connector::{ClientConnector, ClientConnectorState, Credentials, DesktopSize, Sequence as _, Written}; +use ironrdp_core::{WriteBuf, encode_vec}; +use ironrdp_pdu::gcc; +use ironrdp_pdu::mcs::{McsMessage, SendDataIndication}; +use ironrdp_pdu::rdp::autodetect::{AutoDetectReqPdu, AutoDetectRequest}; +use ironrdp_pdu::rdp::capability_sets::MajorPlatformType; +use ironrdp_pdu::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; +use ironrdp_pdu::rdp::server_license::{ + LicenseErrorCode, LicenseHeader, LicensePdu, LicensingErrorMessage, LicensingStateTransition, PreambleFlags, + PreambleType, PreambleVersion, +}; +use ironrdp_pdu::x224::X224; + +const USER_CHANNEL_ID: u16 = 1002; +const IO_CHANNEL_ID: u16 = 1003; +const MESSAGE_CHANNEL_ID: u16 = 1004; + +fn test_config() -> ironrdp_connector::Config { + ironrdp_connector::Config { + desktop_size: DesktopSize { + width: 1024, + height: 768, + }, + desktop_scale_factor: 0, + enable_tls: true, + enable_credssp: false, + credentials: Credentials::UsernamePassword { + username: "test".into(), + password: "test".into(), + }, + domain: None, + client_build: 0, + client_name: "test".into(), + keyboard_type: gcc::KeyboardType::IbmEnhanced, + keyboard_subtype: 0, + keyboard_layout: 0, + keyboard_functional_keys_count: 12, + ime_file_name: String::new(), + bitmap: None, + dig_product_id: String::new(), + client_dir: String::new(), + platform: MajorPlatformType::UNIX, + hardware_id: None, + request_data: None, + autologon: false, + enable_audio_playback: false, + license_cache: None, + compression_type: None, + enable_server_pointer: false, + pointer_software_rendering: false, + multitransport_flags: None, + performance_flags: Default::default(), + timezone_info: Default::default(), + alternate_shell: String::new(), + work_dir: String::new(), + } +} + +/// A client connector parked in `ConnectTimeAutoDetection` with a negotiated +/// message channel, ready to receive the first PDU of that phase. +fn connect_time_autodetect_connector() -> ClientConnector { + let mut connector = ClientConnector::new(test_config(), "127.0.0.1:12345".parse().unwrap()); + connector.state = ClientConnectorState::ConnectTimeAutoDetection { + io_channel_id: IO_CHANNEL_ID, + user_channel_id: USER_CHANNEL_ID, + }; + connector.message_channel_id = Some(MESSAGE_CHANNEL_ID); + connector +} + +/// Frame a server-to-client SendDataIndication on the given MCS channel. +fn server_send_data_indication(channel_id: u16, user_data: Vec) -> Vec { + let indication = McsMessage::SendDataIndication(SendDataIndication { + initiator_id: USER_CHANNEL_ID, + channel_id, + user_data: Cow::Owned(user_data), + }); + + encode_vec(&X224(indication)).unwrap() +} + +#[test] +fn connect_time_autodetect_request_is_answered_and_phase_continues() { + let mut connector = connect_time_autodetect_connector(); + + let user_data = encode_vec(&AutoDetectReqPdu::new(AutoDetectRequest::rtt_connect_time(0x1234))).unwrap(); + let frame = server_send_data_indication(MESSAGE_CHANNEL_ID, user_data); + + let mut output = WriteBuf::new(); + let written = connector.step(&frame, &mut output).unwrap(); + + assert!(written.size().is_some(), "an RTT request must produce a response frame"); + assert!( + matches!(connector.state, ClientConnectorState::ConnectTimeAutoDetection { .. }), + "the connector keeps listening after answering an auto-detect request" + ); +} + +#[test] +fn unrelated_message_channel_pdu_is_ignored_and_phase_continues() { + let mut connector = connect_time_autodetect_connector(); + + // A message-channel PDU that is not an auto-detect request: a bare security + // header without the SEC_AUTODETECT_REQ flag. It must be ignored, not handed + // to the licensing sequence (which would try to decode it as a license PDU). + let user_data = encode_vec(&BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::HEARTBEAT, + }) + .unwrap(); + let frame = server_send_data_indication(MESSAGE_CHANNEL_ID, user_data); + + let mut output = WriteBuf::new(); + let written = connector.step(&frame, &mut output).unwrap(); + + assert_eq!( + written, + Written::Nothing, + "an unrelated message-channel PDU produces no response" + ); + assert!( + matches!(connector.state, ClientConnectorState::ConnectTimeAutoDetection { .. }), + "the connector keeps listening on the message channel" + ); +} + +#[test] +fn first_licensing_pdu_leaves_autodetect_for_the_licensing_path() { + let mut connector = connect_time_autodetect_connector(); + + // The first PDU that is not on the message channel is the licensing PDU on + // the I/O channel. A STATUS_VALID_CLIENT license error completes licensing in + // a single step ([MS-RDPELE] 3.1.5.3.1), so the connector advances out of + // auto-detection into multitransport bootstrapping. + let license = LicensePdu::LicensingErrorMessage(LicensingErrorMessage { + license_header: LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, + }, + preamble_message_type: PreambleType::ErrorAlert, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: 0x10, + }, + error_code: LicenseErrorCode::StatusValidClient, + state_transition: LicensingStateTransition::NoTransition, + error_info: Vec::new(), + }); + let user_data = encode_vec(&license).unwrap(); + let frame = server_send_data_indication(IO_CHANNEL_ID, user_data); + + let mut output = WriteBuf::new(); + connector.step(&frame, &mut output).unwrap(); + + assert!( + matches!( + connector.state, + ClientConnectorState::MultitransportBootstrapping { .. } + ), + "a completed licensing exchange advances the connector out of auto-detection" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/connector/mod.rs b/crates/ironrdp-testsuite-core/tests/connector/mod.rs new file mode 100644 index 0000000000..06f642f83d --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/connector/mod.rs @@ -0,0 +1 @@ +mod autodetect; diff --git a/crates/ironrdp-testsuite-core/tests/main.rs b/crates/ironrdp-testsuite-core/tests/main.rs index 551d311f3f..c389806e2b 100644 --- a/crates/ironrdp-testsuite-core/tests/main.rs +++ b/crates/ironrdp-testsuite-core/tests/main.rs @@ -14,6 +14,7 @@ mod cfg; mod clipboard; +mod connector; mod displaycontrol; mod dvc; mod echo; diff --git a/crates/ironrdp-testsuite-core/tests/session/autodetect.rs b/crates/ironrdp-testsuite-core/tests/session/autodetect.rs index 1d8e8f0eef..3bc8eac615 100644 --- a/crates/ironrdp-testsuite-core/tests/session/autodetect.rs +++ b/crates/ironrdp-testsuite-core/tests/session/autodetect.rs @@ -2,43 +2,36 @@ use std::borrow::Cow; use ironrdp_core::encode_vec; use ironrdp_pdu::mcs::{McsMessage, SendDataIndication}; -use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; -use ironrdp_pdu::rdp::client_info::CompressionType; -use ironrdp_pdu::rdp::headers::{ - CompressionFlags, ShareControlHeader, ShareControlPdu, ShareDataHeader, ShareDataPdu, StreamPriority, -}; +use ironrdp_pdu::rdp::autodetect::{AutoDetectReqPdu, AutoDetectRequest, AutoDetectResponse, AutoDetectRspPdu}; use ironrdp_pdu::x224::X224; use ironrdp_session::x224::Processor; use ironrdp_svc::StaticChannelSet; const USER_CHANNEL_ID: u16 = 1002; const IO_CHANNEL_ID: u16 = 1003; +const MESSAGE_CHANNEL_ID: u16 = 1004; const SHARE_ID: u32 = 0x0001_0000; fn make_processor() -> Processor { - Processor::new(StaticChannelSet::new(), USER_CHANNEL_ID, IO_CHANNEL_ID, SHARE_ID) + Processor::new( + StaticChannelSet::new(), + USER_CHANNEL_ID, + IO_CHANNEL_ID, + Some(MESSAGE_CHANNEL_ID), + SHARE_ID, + ) } -/// Encode a ShareDataPdu as a server-to-client SendDataIndication frame. -fn encode_server_share_data(pdu: ShareDataPdu) -> Vec { - let share_data_header = ShareDataHeader { - share_data_pdu: pdu, - stream_priority: StreamPriority::Medium, - compression_flags: CompressionFlags::empty(), - compression_type: CompressionType::K8, - }; - - let share_control_header = ShareControlHeader { - share_control_pdu: ShareControlPdu::Data(share_data_header), - pdu_source: USER_CHANNEL_ID, - share_id: SHARE_ID, - }; - - let user_data = encode_vec(&share_control_header).unwrap(); +/// Encode an Auto-Detect Request as a server-to-client SendDataIndication on the +/// MCS message channel ([MS-RDPBCGR] 2.2.14.3): the auto-detect data is framed by +/// a Basic Security Header (SEC_AUTODETECT_REQ), not a Share Data header. +fn encode_server_autodetect(request: AutoDetectRequest) -> Vec { + let pdu = AutoDetectReqPdu::new(request); + let user_data = encode_vec(&pdu).unwrap(); let indication = McsMessage::SendDataIndication(SendDataIndication { initiator_id: USER_CHANNEL_ID, - channel_id: IO_CHANNEL_ID, + channel_id: MESSAGE_CHANNEL_ID, user_data: Cow::Owned(user_data), }); @@ -49,7 +42,7 @@ fn encode_server_share_data(pdu: ShareDataPdu) -> Vec { fn rtt_request_produces_response_frame() { let mut processor = make_processor(); let request = AutoDetectRequest::rtt_continuous(42); - let frame = encode_server_share_data(ShareDataPdu::AutoDetectReq(request)); + let frame = encode_server_autodetect(request); let outputs = processor.process(&frame).unwrap(); @@ -67,7 +60,7 @@ fn rtt_response_preserves_sequence_number() { let mut processor = make_processor(); let sequence_number = 0x1234; let request = AutoDetectRequest::rtt_connect_time(sequence_number); - let frame = encode_server_share_data(ShareDataPdu::AutoDetectReq(request)); + let frame = encode_server_autodetect(request); let outputs = processor.process(&frame).unwrap(); @@ -76,24 +69,25 @@ fn rtt_response_preserves_sequence_number() { panic!("expected ResponseFrame"); }; - // The response frame wraps X224 > MCS SendDataRequest > ShareControl > ShareData > AutoDetectRsp. - // Decode the MCS layer to extract user data, then decode the share headers. + // The response is a Client Auto-Detect Response PDU on the message channel: + // X224 > MCS SendDataRequest > BasicSecurityHeader(SEC_AUTODETECT_RSP) > data. let mcs_msg = ironrdp_core::decode::>>(response_data).unwrap(); let McsMessage::SendDataRequest(send_data) = mcs_msg.0 else { panic!("expected SendDataRequest in response frame"); }; - let share_control = ironrdp_core::decode::(&send_data.user_data).unwrap(); - let ShareControlPdu::Data(share_data) = share_control.share_control_pdu else { - panic!("expected Data PDU in ShareControl"); - }; - - match share_data.share_data_pdu { - ShareDataPdu::AutoDetectRsp(AutoDetectResponse::RttResponse { + assert_eq!( + send_data.channel_id, MESSAGE_CHANNEL_ID, + "response must be sent on the message channel" + ); + + let response = ironrdp_core::decode::(&send_data.user_data).unwrap(); + match response.response { + AutoDetectResponse::RttResponse { sequence_number: rsp_seq, - }) => { + } => { assert_eq!(rsp_seq, sequence_number, "sequence number must be echoed"); } - other => panic!("expected AutoDetectRsp(RttResponse), got {other:?}"), + other => panic!("expected RttResponse, got {other:?}"), } } @@ -101,7 +95,7 @@ fn rtt_response_preserves_sequence_number() { fn network_characteristics_result_surfaces_as_autodetect() { let mut processor = make_processor(); let request = AutoDetectRequest::netchar_result(7, 10, 50000, 20); - let frame = encode_server_share_data(ShareDataPdu::AutoDetectReq(request.clone())); + let frame = encode_server_autodetect(request.clone()); let outputs = processor.process(&frame).unwrap(); @@ -118,7 +112,7 @@ fn network_characteristics_result_surfaces_as_autodetect() { fn bandwidth_measure_start_does_not_crash() { let mut processor = make_processor(); let request = AutoDetectRequest::bw_start_connect_time(100); - let frame = encode_server_share_data(ShareDataPdu::AutoDetectReq(request)); + let frame = encode_server_autodetect(request); let outputs = processor.process(&frame).unwrap(); assert!(outputs.is_empty(), "BW start should produce no output"); @@ -128,7 +122,7 @@ fn bandwidth_measure_start_does_not_crash() { fn bandwidth_measure_stop_does_not_crash() { let mut processor = make_processor(); let request = AutoDetectRequest::bw_stop_continuous(200); - let frame = encode_server_share_data(ShareDataPdu::AutoDetectReq(request)); + let frame = encode_server_autodetect(request); let outputs = processor.process(&frame).unwrap(); assert!(outputs.is_empty(), "BW stop should produce no output"); @@ -138,7 +132,7 @@ fn bandwidth_measure_stop_does_not_crash() { fn bandwidth_measure_payload_does_not_crash() { let mut processor = make_processor(); let request = AutoDetectRequest::bw_payload(300, vec![0xAA; 64]); - let frame = encode_server_share_data(ShareDataPdu::AutoDetectReq(request)); + let frame = encode_server_autodetect(request); let outputs = processor.process(&frame).unwrap(); assert!(outputs.is_empty(), "BW payload should produce no output"); diff --git a/crates/ironrdp-testsuite-extra/tests/e2e.rs b/crates/ironrdp-testsuite-extra/tests/e2e.rs index bcb8de3a09..a0ee690bf5 100644 --- a/crates/ironrdp-testsuite-extra/tests/e2e.rs +++ b/crates/ironrdp-testsuite-extra/tests/e2e.rs @@ -328,6 +328,7 @@ where static_channels: connection_result.static_channels, user_channel_id: connection_result.user_channel_id, io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, share_id: connection_result.share_id, compression_type: connection_result.compression_type, enable_server_pointer: connection_result.enable_server_pointer, diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index 11bc06950f..b22e44482b 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -667,6 +667,7 @@ impl iron_remote_desktop::Session for Session { static_channels: connection_result.static_channels, user_channel_id: connection_result.user_channel_id, io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, share_id: connection_result.share_id, compression_type: connection_result.compression_type, enable_server_pointer: connection_result.enable_server_pointer, diff --git a/crates/ironrdp/examples/screenshot.rs b/crates/ironrdp/examples/screenshot.rs index a5d82fca10..b62a4c51f8 100644 --- a/crates/ironrdp/examples/screenshot.rs +++ b/crates/ironrdp/examples/screenshot.rs @@ -348,6 +348,7 @@ fn active_stage( static_channels: connection_result.static_channels, user_channel_id: connection_result.user_channel_id, io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, share_id: connection_result.share_id, compression_type: connection_result.compression_type, enable_server_pointer: connection_result.enable_server_pointer, diff --git a/ffi/src/session/mod.rs b/ffi/src/session/mod.rs index ae6f6c7ebb..29659e9dfd 100644 --- a/ffi/src/session/mod.rs +++ b/ffi/src/session/mod.rs @@ -53,6 +53,7 @@ pub mod ffi { static_channels: connection_result.static_channels, user_channel_id: connection_result.user_channel_id, io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, share_id: connection_result.share_id, compression_type: connection_result.compression_type, enable_server_pointer: connection_result.enable_server_pointer, From 131d7d3dc540359dcec73f96560561abe534bbbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:08:11 +0900 Subject: [PATCH 319/325] ci(xtask): add dependency-invariant guard for ironrdp-session (#1438) --- .github/workflows/ci.yml | 3 +++ xtask/src/check.rs | 47 ++++++++++++++++++++++++++++++++++++++++ xtask/src/cli.rs | 3 +++ xtask/src/main.rs | 2 ++ 4 files changed, 55 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bca3fadfe5..e64274c5aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,9 @@ jobs: - name: Lints run: cargo xtask check lints -v + - name: Dependencies + run: cargo xtask check dependencies -v + - name: WASM (prepare) run: cargo xtask wasm install -v diff --git a/xtask/src/check.rs b/xtask/src/check.rs index 9bbf88dacf..bfcaa88e73 100644 --- a/xtask/src/check.rs +++ b/xtask/src/check.rs @@ -44,6 +44,53 @@ pub fn typos(sh: &Shell) -> anyhow::Result<()> { Ok(()) } +pub fn dependencies(sh: &Shell) -> anyhow::Result<()> { + let _s = Section::new("DEPENDENCIES"); + + // Dependency-graph invariants that must hold to keep crate boundaries slim. + // Each pair `(package, banned)` asserts that `package` has no transitive + // (non-dev) edge to `banned`, ensuring consumers can depend on the + // former without pulling in the latter’s graph. + const FORBIDDEN: &[(&str, &str)] = &[("ironrdp-session", "ironrdp-connector"), ("ironrdp-session", "sspi")]; + + let mut violations = Vec::new(); + + for &(package, banned) in FORBIDDEN { + // `cargo tree -i` inverts the graph to show what depends on `banned`, + // scoped to `package`’s subtree. When there is no such edge, cargo exits + // non-zero with a "did not match any packages" error; a successful, + // non-empty output means the forbidden edge is present. + let output = cmd!(sh, "{CARGO} tree -p {package} -e no-dev -i {banned}") + .ignore_status() + .quiet() + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let expected_no_match = format!("package ID specification `{banned}` did not match any packages"); + + if output.status.success() && !stdout.trim().is_empty() { + println!("Forbidden dependency edge: `{package}` depends on `{banned}`"); + print!("{stdout}"); + violations.push((package, banned)); + } else if output.status.success() || stderr.contains(expected_no_match.as_str()) { + println!("`{package}` has no dependency on `{banned}` (good)"); + } else { + print!("{stdout}"); + eprint!("{stderr}"); + anyhow::bail!("failed to inspect dependency edge `{package}` -> `{banned}`"); + } + } + + if !violations.is_empty() { + anyhow::bail!("forbidden dependency edge(s) detected, see output above"); + } + + println!("All good!"); + + Ok(()) +} + pub fn install(sh: &Shell) -> anyhow::Result<()> { let _s = Section::new("CHECK-INSTALL"); diff --git a/xtask/src/cli.rs b/xtask/src/cli.rs index 3c6f780777..f974a3c138 100644 --- a/xtask/src/cli.rs +++ b/xtask/src/cli.rs @@ -13,6 +13,7 @@ TASKS: check fmt Check formatting check lints Check lints check locks Check for dirty or staged lock files not yet committed + check dependencies Check dependency-graph invariants between crates check tests [--no-run] Compile tests and, unless specified otherwise, run them check typos Check for typos in the codebase check features Run every feature-matrix case sequentially @@ -80,6 +81,7 @@ pub enum Action { CheckFmt, CheckLints, CheckLocks, + CheckDependencies, CheckTests { no_run: bool, }, @@ -132,6 +134,7 @@ pub fn parse_args() -> anyhow::Result { Some("fmt") => Action::CheckFmt, Some("lints") => Action::CheckLints, Some("locks") => Action::CheckLocks, + Some("dependencies") => Action::CheckDependencies, Some("tests") => Action::CheckTests { no_run: args.contains("--no-run"), }, diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4d60995c98..58bab4b7be 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -61,6 +61,7 @@ fn main() -> anyhow::Result<()> { Action::CheckFmt => check::fmt(&sh)?, Action::CheckLints => check::lints(&sh)?, Action::CheckLocks => check::lock_files(&sh)?, + Action::CheckDependencies => check::dependencies(&sh)?, Action::CheckTests { no_run } => { if no_run { check::tests_compile(&sh)?; @@ -93,6 +94,7 @@ fn main() -> anyhow::Result<()> { check::tests_run(&sh)?; check::lints(&sh)?; features::run_all(&sh)?; + check::dependencies(&sh)?; wasm::check(&sh)?; fuzz::run(&sh, None, None)?; web::install(&sh)?; From 85443601fb275827ced39c54c27961de505175a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:35:19 +0900 Subject: [PATCH 320/325] ci: friendly release asset names and drift-free install docs (#1439) Rename prebuilt binary assets to friendly os-arch names (e.g. ironrdp-agent--linux-x64.tar.gz), dropping the Rust target triple from the filename, and make the triple authoritative by passing --target to cargo so a runner/target mismatch fails loudly instead of silently mislabeling an asset. Move the detailed download/verify instructions out of the READMEs and into the GitHub Release body, generated by the workflow so they can never drift from the actual assets. The block is injected idempotently between sentinel markers, so re-running the workflow (the recovery path for a failed matrix leg) replaces it in place rather than appending. The READMEs are demoted to short evergreen pointers to the Releases page. Extract the duplicated native-dependency install steps (Linux ALSA headers, Windows NASM) into a shared .github/actions/install-build-deps composite action reused by ci.yml and release-binaries.yml. --- .github/actions/install-build-deps/action.yml | 20 +++ .github/workflows/ci.yml | 21 +-- .github/workflows/release-binaries.yml | 127 +++++++++++++++--- README.md | 38 +----- crates/ironrdp-agent/README.md | 6 + crates/ironrdp-viewer/README.md | 6 + release-plz.toml | 3 +- 7 files changed, 155 insertions(+), 66 deletions(-) create mode 100644 .github/actions/install-build-deps/action.yml diff --git a/.github/actions/install-build-deps/action.yml b/.github/actions/install-build-deps/action.yml new file mode 100644 index 0000000000..e055089cdd --- /dev/null +++ b/.github/actions/install-build-deps/action.yml @@ -0,0 +1,20 @@ +name: Install build dependencies +description: Install the native build dependencies required to build IronRDP (Linux ALSA headers, Windows NASM). + +runs: + using: composite + steps: + - name: Install devel packages + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + sudo apt-get update -qq + sudo apt-get -y install libasound2-dev + + - name: Install NASM + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + choco install nasm + $Env:PATH += ";$Env:ProgramFiles\NASM" + echo "PATH=$Env:PATH" >> $Env:GITHUB_ENV diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e64274c5aa..2e3bae92be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,19 +74,8 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Install devel packages - if: ${{ runner.os == 'Linux' }} - run: | - sudo apt-get update -qq - sudo apt-get -y install libasound2-dev - - - name: Install NASM - if: ${{ runner.os == 'Windows' }} - run: | - choco install nasm - $Env:PATH += ";$Env:ProgramFiles\NASM" - echo "PATH=$Env:PATH" >> $Env:GITHUB_ENV - shell: pwsh + - name: Install build dependencies + uses: ./.github/actions/install-build-deps - name: Rust cache uses: Swatinem/rust-cache@v2.7.3 @@ -235,10 +224,8 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Install devel packages - run: | - sudo apt-get update -qq - sudo apt-get -y install libasound2-dev + - name: Install build dependencies + uses: ./.github/actions/install-build-deps - name: Rust cache uses: Swatinem/rust-cache@v2.7.3 diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index e58278d587..182bb95288 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -51,20 +51,29 @@ jobs: strategy: fail-fast: false matrix: + # `target` is each runner's native triple; it is passed to cargo (see the build step) so a + # runner/target mismatch fails the build loudly instead of silently mislabeling an asset. + # `os_arch` is the friendly, triple-free label used for the asset and artifact names. include: - runner: windows-2022 target: x86_64-pc-windows-msvc + os_arch: windows-x64 - runner: windows-11-arm target: aarch64-pc-windows-msvc + os_arch: windows-arm64 - runner: ubuntu-22.04 target: x86_64-unknown-linux-gnu + os_arch: linux-x64 - runner: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu + os_arch: linux-arm64 - runner: macos-15-intel target: x86_64-apple-darwin + os_arch: macos-x64 macos_deployment_target: '10.13' - runner: macos-14 target: aarch64-apple-darwin + os_arch: macos-arm64 macos_deployment_target: '11.0' steps: @@ -73,19 +82,8 @@ jobs: with: ref: ${{ github.event.release.tag_name }} - - name: Install Linux build dependencies - if: ${{ runner.os == 'Linux' }} - run: | - sudo apt-get update -qq - sudo apt-get -y install libasound2-dev - - - name: Install NASM - if: ${{ runner.os == 'Windows' }} - run: | - choco install nasm - $Env:PATH += ";$Env:ProgramFiles\NASM" - echo "PATH=$Env:PATH" >> $Env:GITHUB_ENV - shell: pwsh + - name: Install build dependencies + uses: ./.github/actions/install-build-deps - name: Rust cache uses: Swatinem/rust-cache@v2.7.3 @@ -101,7 +99,8 @@ jobs: $env:MACOSX_DEPLOYMENT_TARGET = '${{ matrix.macos_deployment_target }}' } - cargo build --locked --release --package '${{ needs.select-package.outputs.package }}' + rustup target add '${{ matrix.target }}' + cargo build --locked --release --target '${{ matrix.target }}' --package '${{ needs.select-package.outputs.package }}' - name: Package binary shell: pwsh @@ -109,10 +108,11 @@ jobs: PACKAGE: ${{ needs.select-package.outputs.package }} VERSION: ${{ needs.select-package.outputs.version }} TARGET: ${{ matrix.target }} + OS_ARCH: ${{ matrix.os_arch }} run: | $extension = if ($env:RUNNER_OS -eq 'Windows') { '.exe' } else { '' } - $binary = Join-Path (Join-Path 'target' 'release') "$env:PACKAGE$extension" - $assetName = "$env:PACKAGE-$env:VERSION-$env:TARGET.tar.gz" + $binary = Join-Path (Join-Path (Join-Path 'target' $env:TARGET) 'release') "$env:PACKAGE$extension" + $assetName = "$env:PACKAGE-$env:VERSION-$env:OS_ARCH.tar.gz" $assetDirectory = 'release-assets' New-Item -ItemType Directory -Force -Path $assetDirectory | Out-Null @@ -128,14 +128,22 @@ jobs: - name: Upload release asset uses: actions/upload-artifact@v7 with: - name: release-assets-${{ matrix.target }} + name: release-assets-${{ matrix.os_arch }} path: release-assets/* if-no-files-found: error retention-days: 1 + # Allow a full "Re-run all jobs" (the recovery path) to re-upload over a prior attempt's + # artifact of the same name instead of failing. + overwrite: true publish: name: Upload release assets needs: [select-package, build] + # All-or-nothing on purpose: with `fail-fast: false`, if any one of the matrix legs fails, + # `needs.build.result` is not 'success' and NO assets are uploaded, rather than publishing a + # partial set. The Release itself is already published by release-plz, so a failed build leaves + # it without binaries; recovery is re-running this workflow. The final `gh release upload + # --clobber` and the notes step below are both idempotent, so re-runs are safe. if: ${{ always() && needs.select-package.outputs.package != '' && needs.build.result == 'success' }} runs-on: ubuntu-latest @@ -152,3 +160,88 @@ jobs: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ github.event.release.tag_name }} run: gh release upload "$TAG_NAME" release-assets/* --clobber + + - name: Update release notes with install instructions + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.event.release.tag_name }} + PACKAGE: ${{ needs.select-package.outputs.package }} + VERSION: ${{ needs.select-package.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + + $begin = '' + $end = '' + + # Literal template: the ${PACKAGE}/${VERSION} placeholders are substituted below; the tag is + # derived as ${PACKAGE}-v${VERSION}. Example variables ($ASSET, $BASE, $Asset) stay literal + # because the here-string is single-quoted (no interpolation). + $template = @' + + ## Prebuilt binaries + + Prebuilt, checksummed archives of `${PACKAGE}` are attached to this release: + + ${ASSET_LIST} + + Each archive has a matching `.sha256` sidecar. The Windows archive contains `${PACKAGE}.exe`; + the others contain the bare `${PACKAGE}` executable. + + ### Download and verify + + Pick an archive from the list above, then download, verify, and extract it. The two blocks + below are self-contained; use the one for your platform. + + Linux and macOS (POSIX shell): + + ```shell + ASSET="" + BASE="https://github.com/Devolutions/IronRDP/releases/download/${PACKAGE}-v${VERSION}" + curl -fLO "$BASE/$ASSET" + curl -fLO "$BASE/$ASSET.sha256" + sha256sum --check "$ASSET.sha256" # on macOS: shasum -a 256 --check "$ASSET.sha256" + tar -xzf "$ASSET" + ``` + + Windows (PowerShell): + + ```powershell + $Asset = "" + $Base = "https://github.com/Devolutions/IronRDP/releases/download/${PACKAGE}-v${VERSION}" + curl.exe -fLO "$Base/$Asset" + curl.exe -fLO "$Base/$Asset.sha256" + if ((Get-FileHash -Algorithm SHA256 $Asset).Hash.ToLowerInvariant() -ne (Get-Content "$Asset.sha256").Split(' ')[0]) { throw 'checksum mismatch' } + tar -xzf $Asset + ``` + + ### Build baselines + + - Linux archives are built on Ubuntu 22.04 and require glibc 2.35 or later. + - macOS archives target macOS 10.13 or later on Intel and macOS 11.0 or later on Apple Silicon. + + Assets are attached to this release (tag `${PACKAGE}-v${VERSION}`). Don't rely on the `latest` release URL: + releases are tagged per package (`ironrdp-agent-v*`, `ironrdp-viewer-v*`), so `latest` may resolve + to the other package. + + '@ + + # Build the asset list from the archives actually attached to this release, so it can never + # drift from the build matrix (adding/renaming a target updates the notes automatically). + $assetList = (Get-ChildItem -Path release-assets -Filter *.tar.gz | Sort-Object Name | ForEach-Object { "- ``$($_.Name)``" }) -join "`n" + + $block = $template.Replace('${PACKAGE}', $env:PACKAGE).Replace('${VERSION}', $env:VERSION).Replace('${ASSET_LIST}', $assetList) + + $body = (gh release view $env:TAG_NAME --json body -q .body | Out-String).TrimEnd() + + # Idempotent inject: replace the sentinel-delimited region if present, otherwise append. + $beginIndex = $body.IndexOf($begin) + $endIndex = $body.IndexOf($end) + if ($beginIndex -ge 0 -and $endIndex -ge 0) { + $newBody = $body.Substring(0, $beginIndex) + $block + $body.Substring($endIndex + $end.Length) + } else { + $newBody = if ($body) { "$body`n`n$block" } else { $block } + } + + Set-Content -Path newbody.md -Value $newBody -Encoding utf8 + gh release edit $env:TAG_NAME --notes-file newbody.md diff --git a/README.md b/README.md index a74a826011..fb3b3e8ceb 100644 --- a/README.md +++ b/README.md @@ -67,39 +67,15 @@ Alternatively, you may change a few group policies using `gpedit.msc`: ## Binary releases -Standalone archives are attached to GitHub Releases for the executable packages: +Prebuilt, checksummed `.tar.gz` archives are attached to each GitHub Release for the executable +packages, one per supported platform: -- [`ironrdp-agent`](./crates/ironrdp-agent) provides the agentic, daemon-backed CLI. -- [`ironrdp-viewer`](./crates/ironrdp-viewer) provides the windowed RDP client CLI. +- [`ironrdp-agent`](./crates/ironrdp-agent) — the agentic, daemon-backed CLI. +- [`ironrdp-viewer`](./crates/ironrdp-viewer) — the windowed RDP client CLI. -Each release provides one `.tar.gz` archive and a SHA-256 sidecar for these native target triples: - -| Platform | Target triple | -| --- | --- | -| Windows x64 | `x86_64-pc-windows-msvc` | -| Windows ARM64 | `aarch64-pc-windows-msvc` | -| Linux x64 | `x86_64-unknown-linux-gnu` | -| Linux ARM64 | `aarch64-unknown-linux-gnu` | -| macOS x64 | `x86_64-apple-darwin` | -| macOS ARM64 | `aarch64-apple-darwin` | - -Linux archives use an Ubuntu 22.04 build baseline and require glibc 2.35 or later. macOS archives -target macOS 10.13 or later on Intel and macOS 11.0 or later on Apple Silicon. - -For example, download and extract the Linux x64 agent from its release: - -```shell -VERSION= -ASSET="ironrdp-agent-${VERSION}-x86_64-unknown-linux-gnu.tar.gz" -curl -fLO "https://github.com/Devolutions/IronRDP/releases/download/ironrdp-agent-v${VERSION}/${ASSET}" -curl -fLO "https://github.com/Devolutions/IronRDP/releases/download/ironrdp-agent-v${VERSION}/${ASSET}.sha256" -sha256sum --check "${ASSET}.sha256" -tar -xzf "${ASSET}" -``` - -Replace `ironrdp-agent` with `ironrdp-viewer` to download the windowed client from its corresponding -package release. Windows archives contain an `.exe`; all other archives contain the executable without -an extension. +Each package is released under its own tag (`ironrdp-agent-v*`, `ironrdp-viewer-v*`). See the +[Releases page](https://github.com/Devolutions/IronRDP/releases) to pick a release and follow the +per-platform download, checksum, and extraction instructions included in its notes. ## Rust version (MSRV) diff --git a/crates/ironrdp-agent/README.md b/crates/ironrdp-agent/README.md index ba376a9ea5..4d59f08de1 100644 --- a/crates/ironrdp-agent/README.md +++ b/crates/ironrdp-agent/README.md @@ -13,6 +13,12 @@ The single `ironrdp-agent` binary bundles two roles: Run `ironrdp-agent --help-agent` for a structured, machine-readable description of every operation. +## Prebuilt binaries + +Prebuilt, checksummed archives are attached to each GitHub Release under the `ironrdp-agent-v*` +tags. See the [Releases page](https://github.com/Devolutions/IronRDP/releases) for per-platform +download and verification instructions. + ## Wire format Messages are encoded with [`ironrdp-core`]'s `Encode`/`Decode` traits, length-delimited with a diff --git a/crates/ironrdp-viewer/README.md b/crates/ironrdp-viewer/README.md index 18710c2fbd..2361ec078a 100644 --- a/crates/ironrdp-viewer/README.md +++ b/crates/ironrdp-viewer/README.md @@ -6,6 +6,12 @@ This is a a full-fledged RDP client based on IronRDP crates suite, and implement non-blocking, asynchronous I/O. Portability is achieved by using softbuffer for rendering and winit for windowing. +## Prebuilt binaries + +Prebuilt, checksummed archives are attached to each GitHub Release under the `ironrdp-viewer-v*` +tags. See the [Releases page](https://github.com/Devolutions/IronRDP/releases) for per-platform +download and verification instructions. + ## Sample usage ```shell diff --git a/release-plz.toml b/release-plz.toml index 080b4d51a3..4f3a3395e6 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -7,7 +7,8 @@ pr_name = "chore(release): prepare for publishing" changelog_config = "cliff.toml" release_commits = "^(feat|docs|fix|build|perf)" -# Flagship crate for which we push a GitHub release. +# Executable crates for which we push a GitHub release (and attach prebuilt binaries via +# release-binaries.yml). Each is released under its own tag (ironrdp-agent-v*, ironrdp-viewer-v*). [[package]] name = "ironrdp-agent" git_release_enable = true From 11a0810cfbbabd8b8023875a05e3041216d4b01b Mon Sep 17 00:00:00 2001 From: devolutionsbot <31221910+devolutionsbot@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:38:47 -0400 Subject: [PATCH 321/325] chore(release): prepare for publishing (#1364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Benoît Cortier <3809077+CBenoit@users.noreply.github.com> --- Cargo.lock | 865 +++++++-------------- crates/ironrdp-acceptor/CHANGELOG.md | 26 + crates/ironrdp-acceptor/Cargo.toml | 10 +- crates/ironrdp-agent/Cargo.toml | 19 +- crates/ironrdp-ainput/CHANGELOG.md | 8 + crates/ironrdp-ainput/Cargo.toml | 4 +- crates/ironrdp-async/CHANGELOG.md | 10 + crates/ironrdp-async/Cargo.toml | 6 +- crates/ironrdp-blocking/CHANGELOG.md | 10 + crates/ironrdp-blocking/Cargo.toml | 6 +- crates/ironrdp-cfg/Cargo.toml | 1 - crates/ironrdp-client/Cargo.toml | 44 +- crates/ironrdp-client/src/ws.rs | 106 ++- crates/ironrdp-cliprdr-native/CHANGELOG.md | 8 + crates/ironrdp-cliprdr-native/Cargo.toml | 4 +- crates/ironrdp-cliprdr/CHANGELOG.md | 28 + crates/ironrdp-cliprdr/Cargo.toml | 6 +- crates/ironrdp-connector/CHANGELOG.md | 43 + crates/ironrdp-connector/Cargo.toml | 6 +- crates/ironrdp-core/CHANGELOG.md | 18 + crates/ironrdp-core/Cargo.toml | 2 +- crates/ironrdp-displaycontrol/CHANGELOG.md | 12 + crates/ironrdp-displaycontrol/Cargo.toml | 8 +- crates/ironrdp-dvc-com-plugin/CHANGELOG.md | 14 + crates/ironrdp-dvc-com-plugin/Cargo.toml | 8 +- crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md | 22 + crates/ironrdp-dvc-pipe-proxy/Cargo.toml | 8 +- crates/ironrdp-dvc/CHANGELOG.md | 14 + crates/ironrdp-dvc/Cargo.toml | 6 +- crates/ironrdp-echo/CHANGELOG.md | 10 + crates/ironrdp-echo/Cargo.toml | 6 +- crates/ironrdp-egfx/CHANGELOG.md | 12 + crates/ironrdp-egfx/Cargo.toml | 8 +- crates/ironrdp-futures/CHANGELOG.md | 8 + crates/ironrdp-futures/Cargo.toml | 4 +- crates/ironrdp-graphics/CHANGELOG.md | 14 + crates/ironrdp-graphics/Cargo.toml | 4 +- crates/ironrdp-input/CHANGELOG.md | 8 + crates/ironrdp-input/Cargo.toml | 4 +- crates/ironrdp-mstsgu/Cargo.toml | 1 - crates/ironrdp-nscodec/Cargo.toml | 4 +- crates/ironrdp-pdu/CHANGELOG.md | 33 + crates/ironrdp-pdu/Cargo.toml | 2 +- crates/ironrdp-propertyset/Cargo.toml | 1 - crates/ironrdp-rdpdr-native/CHANGELOG.md | 12 + crates/ironrdp-rdpdr-native/Cargo.toml | 8 +- crates/ironrdp-rdpdr/CHANGELOG.md | 10 + crates/ironrdp-rdpdr/Cargo.toml | 6 +- crates/ironrdp-rdpeusb/Cargo.toml | 4 +- crates/ironrdp-rdpfile/Cargo.toml | 1 - crates/ironrdp-rdpsnd-native/CHANGELOG.md | 16 + crates/ironrdp-rdpsnd-native/Cargo.toml | 4 +- crates/ironrdp-rdpsnd/CHANGELOG.md | 15 + crates/ironrdp-rdpsnd/Cargo.toml | 6 +- crates/ironrdp-server/CHANGELOG.md | 36 + crates/ironrdp-server/Cargo.toml | 30 +- crates/ironrdp-session/CHANGELOG.md | 38 + crates/ironrdp-session/Cargo.toml | 12 +- crates/ironrdp-svc/CHANGELOG.md | 8 + crates/ironrdp-svc/Cargo.toml | 4 +- crates/ironrdp-tls/CHANGELOG.md | 19 + crates/ironrdp-tls/Cargo.toml | 2 +- crates/ironrdp-tokio/CHANGELOG.md | 10 + crates/ironrdp-tokio/Cargo.toml | 6 +- crates/ironrdp-viewer/Cargo.toml | 11 +- crates/ironrdp/CHANGELOG.md | 45 ++ crates/ironrdp/Cargo.toml | 34 +- fuzz/Cargo.lock | 92 +-- release-plz.toml | 14 +- 69 files changed, 1097 insertions(+), 787 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf3905f4e6..276017db32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,9 +37,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" -version = "0.6.0-rc.10" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b657e772794c6b04730ea897b66a058ccd866c16d1967da05eeeecec39043fe" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", "inout", @@ -118,7 +118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "libc", ] @@ -140,7 +140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.12.1", + "bitflags 2.13.0", "cc", "jni 0.22.4", "libc", @@ -225,9 +225,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -252,9 +252,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-raw-xcb-connection" @@ -285,7 +285,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -297,7 +297,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -327,7 +327,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -338,7 +338,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -364,9 +364,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -374,14 +374,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -445,18 +446,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "arbitrary", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -475,9 +476,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -541,7 +542,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -558,15 +559,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytesize" -version = "2.3.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" [[package]] name = "calloop" @@ -574,7 +575,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "log", "polling", "rustix 0.38.44", @@ -611,9 +612,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -641,9 +642,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -696,7 +697,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout", ] @@ -732,7 +733,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -868,7 +869,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -982,9 +983,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -992,18 +993,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -1011,7 +1012,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crossterm_winapi", "derive_more", "document-features", @@ -1046,7 +1047,7 @@ checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", - "getrandom 0.4.2", + "getrandom 0.4.3", "hybrid-array", "num-traits", "rand_core 0.10.1", @@ -1071,7 +1072,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "hybrid-array", "rand_core 0.10.1", ] @@ -1088,12 +1089,11 @@ dependencies = [ [[package]] name = "crypto-primes" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" dependencies = [ "crypto-bigint", - "libm", "rand_core 0.10.1", ] @@ -1103,7 +1103,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cryptoki-sys", "libloading", "log", @@ -1177,7 +1177,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1207,9 +1207,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", "pem-rfc7468 1.0.0", @@ -1237,7 +1237,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1245,9 +1245,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_arbitrary" @@ -1257,7 +1254,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1279,7 +1276,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1313,7 +1310,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", @@ -1327,7 +1324,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1347,7 +1344,7 @@ dependencies = [ "serde", "smallvec", "strck_ident", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1362,7 +1359,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "objc2 0.6.4", ] @@ -1374,7 +1371,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1419,7 +1416,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytemuck", "drm-ffi", "drm-fourcc", @@ -1477,7 +1474,7 @@ version = "0.17.0-rc.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c72d1455753a703ad4b90ed2a759f2bc4562024a303176439cf6e593b5ade4" dependencies = [ - "der 0.8.0", + "der 0.8.1", "digest 0.11.3", "elliptic-curve", "rfc6979", @@ -1517,9 +1514,9 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3273f1195b6f6253ebda493d6742c8baa9b26a291674cd96d92a0f09e90e9b46" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ "base16ct", "crypto-bigint", @@ -1539,9 +1536,9 @@ dependencies = [ [[package]] name = "embed-resource" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", @@ -1656,12 +1653,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1689,7 +1680,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1781,7 +1772,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1877,17 +1868,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -1962,9 +1951,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1999,15 +1988,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -2065,9 +2045,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2104,9 +2084,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "subtle", "typenum", @@ -2297,12 +2277,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "idna" version = "1.1.0" @@ -2344,9 +2318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "hashbrown", ] [[package]] @@ -2365,7 +2337,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crossterm", "dyn-clone", "fuzzy-matcher", @@ -2393,7 +2365,7 @@ dependencies = [ [[package]] name = "ironrdp" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "async-trait", @@ -2429,7 +2401,7 @@ dependencies = [ [[package]] name = "ironrdp-acceptor" -version = "0.9.0" +version = "0.10.0" dependencies = [ "ironrdp-async", "ironrdp-connector", @@ -2441,7 +2413,7 @@ dependencies = [ [[package]] name = "ironrdp-agent" -version = "0.0.0" +version = "0.1.0" dependencies = [ "anyhow", "clap", @@ -2462,9 +2434,9 @@ dependencies = [ [[package]] name = "ironrdp-ainput" -version = "0.7.0" +version = "0.8.0" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-dvc", "num-derive", @@ -2473,7 +2445,7 @@ dependencies = [ [[package]] name = "ironrdp-async" -version = "0.9.0" +version = "0.10.0" dependencies = [ "bytes", "ironrdp-connector", @@ -2494,7 +2466,7 @@ dependencies = [ [[package]] name = "ironrdp-blocking" -version = "0.9.0" +version = "0.10.0" dependencies = [ "bytes", "ironrdp-connector", @@ -2549,16 +2521,15 @@ dependencies = [ "tokio", "tokio-tungstenite", "tracing", - "transport", "url", "x509-cert", ] [[package]] name = "ironrdp-cliprdr" -version = "0.6.0" +version = "0.7.0" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -2576,7 +2547,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-native" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ironrdp-cliprdr", "ironrdp-core", @@ -2586,7 +2557,7 @@ dependencies = [ [[package]] name = "ironrdp-connector" -version = "0.9.0" +version = "0.10.0" dependencies = [ "ironrdp-core", "ironrdp-error", @@ -2603,14 +2574,14 @@ dependencies = [ [[package]] name = "ironrdp-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ "ironrdp-error", ] [[package]] name = "ironrdp-displaycontrol" -version = "0.7.0" +version = "0.8.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2621,7 +2592,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2631,7 +2602,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc-com-plugin" -version = "0.1.2" +version = "0.1.3" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2644,7 +2615,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc-pipe-proxy" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "ironrdp-core", @@ -2657,7 +2628,7 @@ dependencies = [ [[package]] name = "ironrdp-echo" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2667,11 +2638,11 @@ dependencies = [ [[package]] name = "ironrdp-egfx" -version = "0.2.0" +version = "0.3.0" dependencies = [ "arbitrary", "bit_field", - "bitflags 2.12.1", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-dvc", "ironrdp-graphics", @@ -2686,7 +2657,7 @@ version = "0.2.0" [[package]] name = "ironrdp-futures" -version = "0.7.0" +version = "0.8.0" dependencies = [ "futures-util", "ironrdp-async", @@ -2712,10 +2683,10 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.8.1" +version = "0.9.0" dependencies = [ "bit_field", - "bitflags 2.12.1", + "bitflags 2.13.0", "bitvec", "bmp", "bytemuck", @@ -2730,7 +2701,7 @@ dependencies = [ [[package]] name = "ironrdp-input" -version = "0.6.0" +version = "0.7.0" dependencies = [ "bitvec", "ironrdp-pdu", @@ -2742,7 +2713,7 @@ name = "ironrdp-mstsgu" version = "0.0.1" dependencies = [ "base64", - "bitflags 2.12.1", + "bitflags 2.13.0", "futures-util", "http-body-util", "hyper", @@ -2759,25 +2730,25 @@ dependencies = [ [[package]] name = "ironrdp-nscodec" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ironrdp-graphics", ] [[package]] name = "ironrdp-pdu" -version = "0.8.0" +version = "0.9.0" dependencies = [ "arbitrary", "bit_field", - "bitflags 2.12.1", + "bitflags 2.13.0", "byteorder", "der-parser", "expect-test", "ironrdp-core", "ironrdp-error", "md-5 0.10.6", - "num-bigint 0.4.6", + "num-bigint 0.4.8", "num-derive", "num-integer", "num-traits", @@ -2804,9 +2775,9 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr" -version = "0.6.0" +version = "0.7.0" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-error", "ironrdp-pdu", @@ -2816,7 +2787,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr-native" -version = "0.6.0" +version = "0.7.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2845,9 +2816,9 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.8.1" +version = "0.9.0" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -2857,7 +2828,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd-native" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "bytemuck", @@ -2871,7 +2842,7 @@ dependencies = [ [[package]] name = "ironrdp-server" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "async-trait", @@ -2904,7 +2875,7 @@ dependencies = [ [[package]] name = "ironrdp-session" -version = "0.10.0" +version = "0.11.0" dependencies = [ "ironrdp-bulk", "ironrdp-core", @@ -2933,9 +2904,9 @@ dependencies = [ [[package]] name = "ironrdp-svc" -version = "0.7.0" +version = "0.8.0" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-pdu", ] @@ -3008,7 +2979,7 @@ dependencies = [ [[package]] name = "ironrdp-tls" -version = "0.2.1" +version = "0.2.2" dependencies = [ "tokio", "tokio-native-tls", @@ -3018,7 +2989,7 @@ dependencies = [ [[package]] name = "ironrdp-tokio" -version = "0.9.0" +version = "0.10.0" dependencies = [ "ironrdp-async", "ironrdp-connector", @@ -3063,7 +3034,7 @@ dependencies = [ "futures-util", "getrandom 0.2.17", "getrandom 0.3.4", - "getrandom 0.4.2", + "getrandom 0.4.3", "gloo-net", "gloo-timers", "iron-remote-desktop", @@ -3174,7 +3145,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3202,28 +3173,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3243,12 +3213,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -3265,12 +3229,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libopus_sys" version = "0.3.3" @@ -3284,14 +3242,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "plain", - "redox_syscall 0.8.1", + "redox_syscall 0.9.0", ] [[package]] @@ -3346,9 +3304,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -3405,15 +3363,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -3488,7 +3446,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3518,7 +3476,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -3556,9 +3514,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -3578,7 +3536,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3618,7 +3576,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3652,7 +3610,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -3668,7 +3626,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "objc2 0.6.4", "objc2-core-audio", @@ -3693,7 +3651,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3730,7 +3688,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "objc2 0.6.4", ] @@ -3740,7 +3698,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3752,7 +3710,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.6.2", "dispatch2", "libc", @@ -3765,7 +3723,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -3808,7 +3766,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "dispatch", "libc", @@ -3821,7 +3779,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -3834,7 +3792,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3857,7 +3815,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3869,7 +3827,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3882,7 +3840,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3913,7 +3871,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", @@ -3945,7 +3903,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -3981,9 +3939,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openh264" -version = "0.9.3" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a12b82c14f702c2cece4e0fc28896c6a6bed5317dc13448c86ac41df91a6f82" +checksum = "e6b2b561d2103303e233779545da757ecd35bad82188d619a6d8901f3007e1ff" dependencies = [ "openh264-sys2", "wide", @@ -3991,9 +3949,9 @@ dependencies = [ [[package]] name = "openh264-sys2" -version = "0.9.6" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa9e072e9b270f3b291c80488dc160abc31ecc214ab3bfde937213cfd8c83b32" +checksum = "75a8867e48183bbd9147380227448c065fe456eb30b0ebc68929809c36c30985" dependencies = [ "cc", "libloading", @@ -4004,11 +3962,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4024,7 +3982,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4035,9 +3993,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -4220,7 +4178,7 @@ dependencies = [ "picky-asn1-x509", "pkcs1 0.8.0-rc.4", "primeorder", - "rand 0.10.1", + "rand 0.10.2", "rand_core 0.10.1", "rc2", "rsa", @@ -4297,7 +4255,7 @@ dependencies = [ "picky-asn1", "picky-asn1-der", "picky-asn1-x509", - "rand 0.10.1", + "rand 0.10.2", "rand_core 0.10.1", "serde", "sha1 0.11.0", @@ -4328,7 +4286,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4359,7 +4317,7 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der 0.8.0", + "der 0.8.1", "spki 0.8.0", ] @@ -4369,7 +4327,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.0", + "der 0.8.1", "spki 0.8.0", ] @@ -4419,7 +4377,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -4494,16 +4452,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "primefield" version = "0.14.0" @@ -4562,7 +4510,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.12.1", + "bitflags 2.13.0", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -4575,9 +4523,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "qoicoubeh" @@ -4605,9 +4553,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -4625,14 +4573,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -4646,23 +4595,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -4708,12 +4657,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4761,6 +4710,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -4820,23 +4778,23 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] name = "redox_syscall" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -4846,9 +4804,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -4857,9 +4815,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -4996,15 +4954,15 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.117", + "syn 2.0.118", "unicode-ident", ] [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -5068,7 +5026,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5081,7 +5039,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -5090,9 +5048,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -5127,9 +5085,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -5149,9 +5107,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -5173,9 +5131,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safe_arch" -version = "0.7.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed" dependencies = [ "bytemuck", ] @@ -5231,7 +5189,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", "ctutils", - "der 0.8.0", + "der 0.8.1", "hybrid-array", "subtle", "zeroize", @@ -5252,7 +5210,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5312,7 +5270,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5500,9 +5458,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smithay-client-toolkit" @@ -5510,7 +5468,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "calloop", "calloop-wayland-source", "cursor-icon", @@ -5606,7 +5564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.0", + "der 0.8.1", ] [[package]] @@ -5623,7 +5581,7 @@ checksum = "15294fb005e36e0b0871d8fc0a4f6aac19f9f5440baee229a9a2b2d7de5ed484" dependencies = [ "async-dnssd", "async-recursion", - "bitflags 2.12.1", + "bitflags 2.13.0", "bytemuck", "byteorder", "cfg-if", @@ -5651,7 +5609,7 @@ dependencies = [ "pkcs1 0.8.0-rc.4", "portpicker", "primeorder", - "rand 0.10.1", + "rand 0.10.2", "rand_core 0.10.1", "reqwest", "rsa", @@ -5728,9 +5686,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -5754,7 +5712,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5763,7 +5721,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5791,7 +5749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -5823,7 +5781,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5834,7 +5792,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5848,12 +5806,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "js-sys", "num-conv", "powerfmt", @@ -5864,15 +5821,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -5975,7 +5932,7 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6003,7 +5960,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6129,7 +6086,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -6173,7 +6130,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6229,21 +6186,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "transport" -version = "0.0.0" -source = "git+https://github.com/Devolutions/devolutions-gateway?rev=06e91dfe82751a6502eaf74b6a99663f06f0236d#06e91dfe82751a6502eaf74b6a99663f06f0236d" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "futures-util", - "parking_lot", - "pin-project-lite", - "tokio", - "tracing", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -6305,12 +6247,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.6.1" @@ -6357,7 +6293,7 @@ version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -6389,7 +6325,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6457,20 +6393,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -6484,9 +6411,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -6497,9 +6424,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6507,9 +6434,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6517,60 +6444,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.12.1", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "wayland-backend" version = "0.3.15" @@ -6591,7 +6484,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -6603,7 +6496,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cursor-icon", "wayland-backend", ] @@ -6621,11 +6514,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -6637,7 +6530,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6650,7 +6543,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6682,9 +6575,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6702,9 +6595,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -6724,9 +6617,9 @@ dependencies = [ [[package]] name = "wide" -version = "0.7.33" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" dependencies = [ "bytemuck", "safe_arch", @@ -6822,7 +6715,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6833,7 +6726,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6908,15 +6801,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -6950,30 +6834,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -6995,12 +6862,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -7013,12 +6874,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -7031,24 +6886,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -7061,12 +6904,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -7079,12 +6916,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -7097,12 +6928,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -7115,12 +6940,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -7130,7 +6949,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.12.1", + "bitflags 2.13.0", "block2 0.5.1", "bytemuck", "calloop", @@ -7198,7 +7017,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12dafb3c1468d0a3f5440e21e51614b53d1fdc62c9f82cc861c447906d09c69a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crypto-bigint", "flate2", "iso7816", @@ -7215,100 +7034,12 @@ dependencies = [ "widestring", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.12.1", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -7391,7 +7122,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "dlib", "log", "once_cell", @@ -7454,37 +7185,37 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "yuv" -version = "0.8.14" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89c90da4fb561f9750984de2c5e7f0ba01035d2eb29d69a7f375b1caef37fdf4" +checksum = "5d85a782d94ee43f078bcfd6fa82d4e6a5b2d1cfbbad168e4df5a9f7b39ef48c" dependencies = [ "num-traits", ] [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7504,28 +7235,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7558,7 +7289,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/crates/ironrdp-acceptor/CHANGELOG.md b/crates/ironrdp-acceptor/CHANGELOG.md index 95df3b7ff7..cd78fe14bf 100644 --- a/crates/ironrdp-acceptor/CHANGELOG.md +++ b/crates/ironrdp-acceptor/CHANGELOG.md @@ -6,6 +6,32 @@ 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). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.9.0...ironrdp-acceptor-v0.10.0)] - 2026-07-10 + +### Features + +- Negotiate the MCS message channel ([#1347](https://github.com/Devolutions/IronRDP/issues/1347)) ([efa5732805](https://github.com/Devolutions/IronRDP/commit/efa573280572f3c0f0270a40ae51a154562706cc)) + + Updates the handshake to properly negotiate the MCS message channel by advertising Extended Client Data Blocks support and, when requested by the client, allocating/joining the message channel and surfacing its ID in AcceptorResult. This enables server-initiated PDUs that must use the message channel (e.g., network auto-detect) to have a valid transport. + +- Expose the client's keyboard layout on AcceptorResult ([#1397](https://github.com/Devolutions/IronRDP/issues/1397)) ([5ca84a5724](https://github.com/Devolutions/IronRDP/commit/5ca84a5724f48093193e39a3097c4f4987d64bbe)) + +- Honor the client-requested desktop size ([#1373](https://github.com/Devolutions/IronRDP/issues/1373)) ([d471bd066f](https://github.com/Devolutions/IronRDP/commit/d471bd066f303df22f4767801fd97ecdbf527869)) + + Adds an opt-in server/acceptor knob to negotiate the RDP session desktop size using the client’s originally requested resolution (from GCC Client Core Data) so the server can start at the client’s native size without a Deactivation–Reactivation resize round trip. + +### Build + +- [**breaking**] Update `ironrdp-async` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-connector` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + ## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.8.0...ironrdp-acceptor-v0.9.0)] - 2026-05-27 ### Bug Fixes diff --git a/crates/ironrdp-acceptor/Cargo.toml b/crates/ironrdp-acceptor/Cargo.toml index 68e8e3383e..90e20eae66 100644 --- a/crates/ironrdp-acceptor/Cargo.toml +++ b/crates/ironrdp-acceptor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-acceptor" -version = "0.9.0" +version = "0.10.0" readme = "README.md" description = "State machines to drive an RDP connection acceptance sequence" edition.workspace = true @@ -18,10 +18,10 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public -ironrdp-async = { path = "../ironrdp-async", version = "0.9" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public +ironrdp-async = { path = "../ironrdp-async", version = "0.10" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-agent/Cargo.toml b/crates/ironrdp-agent/Cargo.toml index 641077870b..085c6b4598 100644 --- a/crates/ironrdp-agent/Cargo.toml +++ b/crates/ironrdp-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-agent" -version = "0.0.0" +version = "0.1.0" readme = "README.md" description = "CLI-driven, daemon-backed agentic RDP client suitable for LLM consumption" edition.workspace = true @@ -11,9 +11,6 @@ authors.workspace = true keywords.workspace = true categories.workspace = true -# Not publishing for now. -publish = false - [lib] doctest = false test = false @@ -30,15 +27,15 @@ internal = [] [dependencies] # RDP client engine: only the TLS backend is mandated -ironrdp-client = { path = "../ironrdp-client", features = ["rustls"] } +ironrdp-client = { path = "../ironrdp-client", version = "0.1", features = ["rustls"] } # Configuration model and codecs -ironrdp-core = { path = "../ironrdp-core", features = ["alloc"] } -ironrdp-pdu = { path = "../ironrdp-pdu" } -ironrdp-propertyset = { path = "../ironrdp-propertyset" } -ironrdp-cfg = { path = "../ironrdp-cfg" } -ironrdp-rdpfile = { path = "../ironrdp-rdpfile" } -ironrdp-input = { path = "../ironrdp-input" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } +ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } +ironrdp-cfg = { path = "../ironrdp-cfg", version = "0.1" } +ironrdp-rdpfile = { path = "../ironrdp-rdpfile", version = "0.1" } +ironrdp-input = { path = "../ironrdp-input", version = "0.7" } # Async runtime and IPC transport tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time", "signal"] } diff --git a/crates/ironrdp-ainput/CHANGELOG.md b/crates/ironrdp-ainput/CHANGELOG.md index 2d624448b2..dd5cc20410 100644 --- a/crates/ironrdp-ainput/CHANGELOG.md +++ b/crates/ironrdp-ainput/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.7.0...ironrdp-ainput-v0.8.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency to 0.8 + + + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.6.0...ironrdp-ainput-v0.7.0)] - 2026-06-05 ### Build diff --git a/crates/ironrdp-ainput/Cargo.toml b/crates/ironrdp-ainput/Cargo.toml index a5fec4e787..71bbb228ea 100644 --- a/crates/ironrdp-ainput/Cargo.toml +++ b/crates/ironrdp-ainput/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-ainput" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "AInput dynamic channel implementation" edition.workspace = true @@ -18,7 +18,7 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public bitflags = "2.11" num-derive.workspace = true # TODO: remove num-traits.workspace = true # TODO: remove diff --git a/crates/ironrdp-async/CHANGELOG.md b/crates/ironrdp-async/CHANGELOG.md index 8bad242557..34a854a384 100644 --- a/crates/ironrdp-async/CHANGELOG.md +++ b/crates/ironrdp-async/CHANGELOG.md @@ -6,6 +6,16 @@ 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). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.9.0...ironrdp-async-v0.10.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-connector` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + ## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.8.0...ironrdp-async-v0.9.0)] - 2026-05-27 ### Bug Fixes diff --git a/crates/ironrdp-async/Cargo.toml b/crates/ironrdp-async/Cargo.toml index 777444e28c..af9b297366 100644 --- a/crates/ironrdp-async/Cargo.toml +++ b/crates/ironrdp-async/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-async" -version = "0.9.0" +version = "0.10.0" readme = "README.md" description = "Provides `Future`s wrapping the IronRDP state machines conveniently" edition.workspace = true @@ -17,9 +17,9 @@ doctest = false test = false [dependencies] -ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public tracing = { version = "0.1", features = ["log"] } bytes = "1" # public diff --git a/crates/ironrdp-blocking/CHANGELOG.md b/crates/ironrdp-blocking/CHANGELOG.md index 27d49e34b2..ced129c3f4 100644 --- a/crates/ironrdp-blocking/CHANGELOG.md +++ b/crates/ironrdp-blocking/CHANGELOG.md @@ -6,6 +6,16 @@ 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). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.9.0...ironrdp-blocking-v0.10.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-connector` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + ## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.8.0...ironrdp-blocking-v0.9.0)] - 2026-05-27 ### Bug Fixes diff --git a/crates/ironrdp-blocking/Cargo.toml b/crates/ironrdp-blocking/Cargo.toml index 28791a81a8..15643335bc 100644 --- a/crates/ironrdp-blocking/Cargo.toml +++ b/crates/ironrdp-blocking/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-blocking" -version = "0.9.0" +version = "0.10.0" readme = "README.md" description = "Blocking I/O abstraction wrapping the IronRDP state machines conveniently" edition.workspace = true @@ -17,9 +17,9 @@ doctest = false test = false [dependencies] -ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public tracing = { version = "0.1", features = ["log"] } bytes = "1" # public diff --git a/crates/ironrdp-cfg/Cargo.toml b/crates/ironrdp-cfg/Cargo.toml index 731544b1d1..4c5d35ce3d 100644 --- a/crates/ironrdp-cfg/Cargo.toml +++ b/crates/ironrdp-cfg/Cargo.toml @@ -3,7 +3,6 @@ name = "ironrdp-cfg" version = "0.1.0" readme = "README.md" description = "IronRDP utilities for ironrdp-cfgstore" -publish = false # TODO: publish edition.workspace = true rust-version = "1.89" license.workspace = true diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 9794fb7408..281c1e941c 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -11,9 +11,6 @@ authors.workspace = true keywords.workspace = true categories.workspace = true -# Not publishing for now. -publish = false - [lib] doctest = false test = false @@ -56,30 +53,30 @@ all = [ [dependencies] # Protocols (core features always on) ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } -ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } -ironrdp-session = { path = "../ironrdp-session", version = "0.10" } -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7" } -ironrdp-echo = { path = "../ironrdp-echo", version = "0.3" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public +ironrdp-session = { path = "../ironrdp-session", version = "0.11" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8" } +ironrdp-echo = { path = "../ironrdp-echo", version = "0.4" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } -ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.9", features = ["reqwest"] } -ironrdp-rdcleanpath = { path = "../ironrdp-rdcleanpath" } -ironrdp-cfg = { path = "../ironrdp-cfg" } -ironrdp-propertyset = { path = "../ironrdp-propertyset" } +ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.10", features = ["reqwest"] } +ironrdp-rdcleanpath = { path = "../ironrdp-rdcleanpath", version = "0.2" } +ironrdp-cfg = { path = "../ironrdp-cfg", version = "0.1" } +ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } # public # Optional protocol crates (activated by features above) -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6", optional = true } -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6", optional = true } -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8", optional = true } +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.7", optional = true } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7", optional = true } +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9", optional = true } # Optional backend crates (activated by features above) -ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.6", optional = true } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6", optional = true } -ironrdp-mstsgu = { path = "../ironrdp-mstsgu", optional = true } -ironrdp-dvc-pipe-proxy = { path = "../ironrdp-dvc-pipe-proxy", optional = true } +ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.7", optional = true } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.7", optional = true } +ironrdp-mstsgu = { path = "../ironrdp-mstsgu", version = "0.0.1", optional = true } +ironrdp-dvc-pipe-proxy = { path = "../ironrdp-dvc-pipe-proxy", version = "0.5", optional = true } # Logging tracing = { version = "0.1", features = ["log"] } @@ -87,7 +84,6 @@ tracing = { version = "0.1", features = ["log"] } # Async, futures tokio = { version = "1", features = ["macros", "net", "io-util", "sync", "rt", "time"] } tokio-tungstenite = "0.29" -transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" } futures-util = { version = "0.3", features = ["sink"] } # Utils @@ -97,7 +93,7 @@ url = "2" x509-cert = { version = "0.2", default-features = false, features = ["std"] } [target.'cfg(windows)'.dependencies] -ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin", optional = true } +ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin", version = "0.1", optional = true } [lints] workspace = true diff --git a/crates/ironrdp-client/src/ws.rs b/crates/ironrdp-client/src/ws.rs index 675553b9b1..a40df235c3 100644 --- a/crates/ironrdp-client/src/ws.rs +++ b/crates/ironrdp-client/src/ws.rs @@ -1,3 +1,7 @@ +use core::pin::Pin; +use core::task::{Context, Poll, ready}; +use std::io; + use futures_util::{Sink, SinkExt as _, Stream, StreamExt as _}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio_tungstenite::tungstenite; @@ -14,10 +18,10 @@ where .filter_map(|item| { let mapped = item .map(|msg| match msg { - tungstenite::Message::Text(s) => Some(transport::WsReadMsg::Payload(tungstenite::Bytes::from(s))), - tungstenite::Message::Binary(data) => Some(transport::WsReadMsg::Payload(data)), + tungstenite::Message::Text(s) => Some(WsReadMsg::Payload(tungstenite::Bytes::from(s))), + tungstenite::Message::Binary(data) => Some(WsReadMsg::Payload(data)), tungstenite::Message::Ping(_) | tungstenite::Message::Pong(_) => None, - tungstenite::Message::Close(_) => Some(transport::WsReadMsg::Close), + tungstenite::Message::Close(_) => Some(WsReadMsg::Close), tungstenite::Message::Frame(_) => unreachable!("raw frames are never returned when reading"), }) .transpose(); @@ -30,5 +34,99 @@ where ))) }); - transport::WsStream::new(compat) + WsStream::new(compat) +} + +/// A WebSocket message as consumed by [`WsStream`] when reading. +enum WsReadMsg { + Payload(tungstenite::Bytes), + Close, +} + +/// Wraps a stream/sink of WebSocket messages and exposes it as [`AsyncRead`] + [`AsyncWrite`]. +/// +/// The wrapped `S` is required to be [`Unpin`] so no pinning projection is needed; the caller of +/// [`websocket_compat`] always provides an `Unpin` stream. +struct WsStream { + inner: S, + read_buf: Option, +} + +impl WsStream { + fn new(inner: S) -> Self { + Self { inner, read_buf: None } + } +} + +impl AsyncRead for WsStream +where + S: Stream> + Unpin, + E: core::error::Error + Send + Sync + 'static, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + let this = &mut *self; + + let mut data = if let Some(data) = this.read_buf.take() { + data + } else { + match ready!(Pin::new(&mut this.inner).poll_next(cx)) { + Some(Ok(WsReadMsg::Payload(data))) => data, + Some(Ok(WsReadMsg::Close)) => return Poll::Ready(Ok(())), + Some(Err(e)) => return Poll::Ready(Err(io::Error::other(e))), + None => return Poll::Ready(Ok(())), + } + }; + + let bytes_to_copy = core::cmp::min(buf.remaining(), data.len()); + + let dest = buf.initialize_unfilled_to(bytes_to_copy); + dest.copy_from_slice(&data.split_to(bytes_to_copy)); + buf.advance(bytes_to_copy); + + if !data.is_empty() { + this.read_buf = Some(data); + } + + Poll::Ready(Ok(())) + } +} + +impl AsyncWrite for WsStream +where + S: Sink, Error = E> + Unpin, + E: core::error::Error + Send + Sync + 'static, +{ + fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + let this = &mut *self; + + // Try flushing preemptively. + let _ = Pin::new(&mut this.inner).poll_flush(cx); + + // Make sure the sink is ready to send. + if let Err(e) = ready!(Pin::new(&mut this.inner).poll_ready(cx)) { + return Poll::Ready(Err(io::Error::other(e))); + } + + // Actually submit the new item. If no error occurred, the message is accepted and queued + // (that is: `to_vec` is called only once). + if let Err(e) = Pin::new(&mut this.inner).start_send(buf.to_vec()) { + return Poll::Ready(Err(io::Error::other(e))); + } + + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let res = ready!(Pin::new(&mut self.inner).poll_flush(cx)); + Poll::Ready(res.map_err(io::Error::other)) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let res = ready!(Pin::new(&mut self.inner).poll_close(cx)); + Poll::Ready(res.map_err(io::Error::other)) + } } diff --git a/crates/ironrdp-cliprdr-native/CHANGELOG.md b/crates/ironrdp-cliprdr-native/CHANGELOG.md index b6b4ea00a6..5bf69398cf 100644 --- a/crates/ironrdp-cliprdr-native/CHANGELOG.md +++ b/crates/ironrdp-cliprdr-native/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.6.0...ironrdp-cliprdr-native-v0.7.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-cliprdr` public dependency to 0.7 + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.5.0...ironrdp-cliprdr-native-v0.6.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-cliprdr-native/Cargo.toml b/crates/ironrdp-cliprdr-native/Cargo.toml index c95286351b..38a4294cf2 100644 --- a/crates/ironrdp-cliprdr-native/Cargo.toml +++ b/crates/ironrdp-cliprdr-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-cliprdr-native" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "Native CLIPRDR static channel backend implementations for IronRDP" edition.workspace = true @@ -17,7 +17,7 @@ doctest = false test = false [dependencies] -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6" } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.7" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.2" } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-cliprdr/CHANGELOG.md b/crates/ironrdp-cliprdr/CHANGELOG.md index d95900dba7..c2c748d099 100644 --- a/crates/ironrdp-cliprdr/CHANGELOG.md +++ b/crates/ironrdp-cliprdr/CHANGELOG.md @@ -6,6 +6,34 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.6.0...ironrdp-cliprdr-v0.7.0)] - 2026-07-10 + +### Features + +- Dispatch initiate_file_copy via ClipboardMessage ([#1388](https://github.com/Devolutions/IronRDP/issues/1388)) ([b6325f9ea6](https://github.com/Devolutions/IronRDP/commit/b6325f9ea6900a84643b4415f9ebc7b1010cf3cd)) + + Extends the CLIPRDR backend-facing API to properly support offering clipboard file lists (so later FileContentsRequests can be serviced) by introducing ClipboardMessage::SendInitiateFileCopy(Vec) and wiring it through the in-tree ClipboardMessage dispatchers. + +### Bug Fixes + +- Release outgoing locks before initiating a file copy ([#1375](https://github.com/Devolutions/IronRDP/issues/1375)) ([5d534f10a6](https://github.com/Devolutions/IronRDP/commit/5d534f10a6f62ac7a860521b4e95c8c47b754612)) + +- Lower verbosity of routine logs in library crates ([c36032f91b](https://github.com/Devolutions/IronRDP/commit/c36032f91b27390a2cd34bfb300cfbe099d847a9)) + + Library crates should not emit info! for routine, repeating operations; + that floods the default logs of the final consumer, which owns the + verbosity decision. Reserve info! for rare connection/session lifecycle + milestones, debug! for significant one-off events, and trace! for the + fine-grained detail only needed when nothing else explains a problem. + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.5.0...ironrdp-cliprdr-v0.6.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-cliprdr/Cargo.toml b/crates/ironrdp-cliprdr/Cargo.toml index 3fe8d0ab02..fa5ff661fb 100644 --- a/crates/ironrdp-cliprdr/Cargo.toml +++ b/crates/ironrdp-cliprdr/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-cliprdr" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "CLIPRDR static channel for clipboard implemented as described in MS-RDPECLIP" edition.workspace = true @@ -23,8 +23,8 @@ __test = ["dep:visibility"] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } bitflags = "2.11" visibility = { version = "0.1", optional = true } diff --git a/crates/ironrdp-connector/CHANGELOG.md b/crates/ironrdp-connector/CHANGELOG.md index 1c818e4503..65a88959ba 100644 --- a/crates/ironrdp-connector/CHANGELOG.md +++ b/crates/ironrdp-connector/CHANGELOG.md @@ -6,6 +6,49 @@ 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). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.9.0...ironrdp-connector-v0.10.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Bug Fixes + +- Stay in CapabilitiesExchange when activation handles DeactivateAll ([#1371](https://github.com/Devolutions/IronRDP/issues/1371)) ([a4fde9fc50](https://github.com/Devolutions/IronRDP/commit/a4fde9fc50f41d1534f32e619bbe0bbbddc64f25)) + +- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d)) + + The error constructor helpers in several crates wrap the #[track_caller] + ironrdp_error::Error::new, but were not themselves marked + #[track_caller]. As a result, the captured location pointed at the + helper body instead of the real call site, giving misleading "@ + file:line" info in error reports. + +- Reduce dependency on ironrdp-connector ([#1419](https://github.com/Devolutions/IronRDP/issues/1419)) ([5c22f86a71](https://github.com/Devolutions/IronRDP/commit/5c22f86a7150bc10c26a3be39bfaebf84c67d781)) + + Removes the leftover legacy modules and moves actually useful utilities to ironrdp-pdu crate. + +- [**breaking**] Rework the connection activation API ([#1435](https://github.com/Devolutions/IronRDP/issues/1435)) ([c6a0286dcb](https://github.com/Devolutions/IronRDP/commit/c6a0286dcb49d9ac54c65c4f9325b41e05d541b8)) + + Introduces a ConnectionActivationFactory (exposed on ConnectionResult) + that builds a fresh ConnectionActivationSequence per + Deactivation-Reactivation, replacing ConnectionActivationSequence::reset_clone, + and turns Deactivate-All handling into a bare signal so consumers own the + activation sequence. + +### Build + +- Align sspi and picky dependencies ([#1385](https://github.com/Devolutions/IronRDP/issues/1385)) ([0a461b5d36](https://github.com/Devolutions/IronRDP/commit/0a461b5d366677fd2f0f664a4f0074e4ab697c42)) + + + ## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.8.0...ironrdp-connector-v0.9.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-connector/Cargo.toml b/crates/ironrdp-connector/Cargo.toml index 5feef2a4ef..5860900708 100644 --- a/crates/ironrdp-connector/Cargo.toml +++ b/crates/ironrdp-connector/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-connector" -version = "0.9.0" +version = "0.10.0" readme = "README.md" description = "State machines to drive an RDP connection sequence" edition.workspace = true @@ -22,10 +22,10 @@ qoi = ["ironrdp-pdu/qoi"] qoiz = ["ironrdp-pdu/qoiz"] [dependencies] -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["std"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["std"] } # public sspi = { version = "0.21", features = ["scard"] } url = "2.5" # public rand = { version = "0.9", features = ["std"] } # TODO: dependency injection? diff --git a/crates/ironrdp-core/CHANGELOG.md b/crates/ironrdp-core/CHANGELOG.md index b1b9c17f2d..33b4c23fad 100644 --- a/crates/ironrdp-core/CHANGELOG.md +++ b/crates/ironrdp-core/CHANGELOG.md @@ -6,6 +6,24 @@ 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). +## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.2.0...ironrdp-core-v0.2.1)] - 2026-07-10 + +### Features + +- Add `WriteBuf::filled_mut`, the mutable counterpart of `filled` ([#1374](https://github.com/Devolutions/IronRDP/issues/1374)) ([d3705af18c](https://github.com/Devolutions/IronRDP/commit/d3705af18cff1851f4d48017affcb85aaa678d57)) + +### Bug Fixes + +- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d)) + + The error constructor helpers in several crates wrap the #[track_caller] + ironrdp_error::Error::new, but were not themselves marked + #[track_caller]. As a result, the captured location pointed at the + helper body instead of the real call site, giving misleading "@ + file:line" info in error reports. + + + ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.1.5...ironrdp-core-v0.2.0)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-core/Cargo.toml b/crates/ironrdp-core/Cargo.toml index 1ab410a175..5fa1a15247 100644 --- a/crates/ironrdp-core/Cargo.toml +++ b/crates/ironrdp-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-core" -version = "0.2.0" +version = "0.2.1" readme = "README.md" description = "IronRDP common traits and types" edition.workspace = true diff --git a/crates/ironrdp-displaycontrol/CHANGELOG.md b/crates/ironrdp-displaycontrol/CHANGELOG.md index 8915ea2a49..0d09e4ba4f 100644 --- a/crates/ironrdp-displaycontrol/CHANGELOG.md +++ b/crates/ironrdp-displaycontrol/CHANGELOG.md @@ -6,6 +6,18 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.7.0...ironrdp-displaycontrol-v0.8.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency to 0.8 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.6.0...ironrdp-displaycontrol-v0.7.0)] - 2026-06-05 ### Build diff --git a/crates/ironrdp-displaycontrol/Cargo.toml b/crates/ironrdp-displaycontrol/Cargo.toml index 38c99be566..45349de12a 100644 --- a/crates/ironrdp-displaycontrol/Cargo.toml +++ b/crates/ironrdp-displaycontrol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-displaycontrol" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "Display control dynamic channel extension implementation" edition.workspace = true @@ -18,9 +18,9 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-dvc-com-plugin/CHANGELOG.md b/crates/ironrdp-dvc-com-plugin/CHANGELOG.md index 0ebbb990f7..9cca74face 100644 --- a/crates/ironrdp-dvc-com-plugin/CHANGELOG.md +++ b/crates/ironrdp-dvc-com-plugin/CHANGELOG.md @@ -6,6 +6,20 @@ 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). +## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-com-plugin-v0.1.2...ironrdp-dvc-com-plugin-v0.1.3)] - 2026-07-10 + +### Bug Fixes + +- Lower verbosity of routine logs in library crates ([c36032f91b](https://github.com/Devolutions/IronRDP/commit/c36032f91b27390a2cd34bfb300cfbe099d847a9)) + + Library crates should not emit info! for routine, repeating operations; + that floods the default logs of the final consumer, which owns the + verbosity decision. Reserve info! for rare connection/session lifecycle + milestones, debug! for significant one-off events, and trace! for the + fine-grained detail only needed when nothing else explains a problem. + + + ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-com-plugin-v0.1.1...ironrdp-dvc-com-plugin-v0.1.2)] - 2026-06-05 diff --git a/crates/ironrdp-dvc-com-plugin/Cargo.toml b/crates/ironrdp-dvc-com-plugin/Cargo.toml index ea783e626c..1c7b9aaba2 100644 --- a/crates/ironrdp-dvc-com-plugin/Cargo.toml +++ b/crates/ironrdp-dvc-com-plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc-com-plugin" -version = "0.1.2" +version = "0.1.3" readme = "README.md" description = "DVC COM client plugin loader for IronRDP (Windows)" edition.workspace = true @@ -20,9 +20,9 @@ test = false [target.'cfg(windows)'.dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } tracing = { version = "0.1", features = ["log"] } windows = { version = "0.62", features = [ "Win32_Foundation", diff --git a/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md index d0bc70a23f..6aacd9649f 100644 --- a/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md +++ b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md @@ -6,6 +6,28 @@ 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). +## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.4.1...ironrdp-dvc-pipe-proxy-v0.5.0)] - 2026-07-10 + +### Bug Fixes + +- Lower verbosity of routine logs in library crates ([c36032f91b](https://github.com/Devolutions/IronRDP/commit/c36032f91b27390a2cd34bfb300cfbe099d847a9)) + + Library crates should not emit info! for routine, repeating operations; + that floods the default logs of the final consumer, which owns the + verbosity decision. Reserve info! for rare connection/session lifecycle + milestones, debug! for significant one-off events, and trace! for the + fine-grained detail only needed when nothing else explains a problem. + +- Remove trailing punctuation from log messages ([#1380](https://github.com/Devolutions/IronRDP/issues/1380)) ([f38554277a](https://github.com/Devolutions/IronRDP/commit/f38554277a1af3085c1fa5739cda515939d09abf)) + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + ## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.4.0...ironrdp-dvc-pipe-proxy-v0.4.1)] - 2026-06-05 diff --git a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml index 5abddda3fc..2780869214 100644 --- a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml +++ b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc-pipe-proxy" -version = "0.4.1" +version = "0.5.0" readme = "README.md" description = "DVC named pipe proxy for IronRDP" edition.workspace = true @@ -18,9 +18,9 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public (PduResult type) -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public (SvcMessage type) +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public (PduResult type) +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public (SvcMessage type) tracing = { version = "0.1", features = ["log"] } tokio = { version = "1", features = ["net", "rt", "sync", "macros", "io-util", "fs"]} diff --git a/crates/ironrdp-dvc/CHANGELOG.md b/crates/ironrdp-dvc/CHANGELOG.md index 693ca8b344..854052722b 100644 --- a/crates/ironrdp-dvc/CHANGELOG.md +++ b/crates/ironrdp-dvc/CHANGELOG.md @@ -6,6 +6,20 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.7.0...ironrdp-dvc-v0.8.0)] - 2026-07-10 + +### Features + +- Expose dynamic channel accessors ([#1368](https://github.com/Devolutions/IronRDP/issues/1368)) ([985d353543](https://github.com/Devolutions/IronRDP/commit/985d353543cf45eacfe0cc57aca86502665a3a44)) + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.6.0...ironrdp-dvc-v0.7.0)] - 2026-06-05 ### Bug Fixes diff --git a/crates/ironrdp-dvc/Cargo.toml b/crates/ironrdp-dvc/Cargo.toml index 13820d05ca..837b85f92c 100644 --- a/crates/ironrdp-dvc/Cargo.toml +++ b/crates/ironrdp-dvc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-dvc" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "DRDYNVC static channel implementation and traits to implement dynamic virtual channels" edition.workspace = true @@ -22,8 +22,8 @@ std = [] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["alloc"] } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-echo/CHANGELOG.md b/crates/ironrdp-echo/CHANGELOG.md index edb991386a..1b4f37fe26 100644 --- a/crates/ironrdp-echo/CHANGELOG.md +++ b/crates/ironrdp-echo/CHANGELOG.md @@ -6,6 +6,16 @@ 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). +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-echo-v0.3.0...ironrdp-echo-v0.4.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency to 0.8 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-echo-v0.2.0...ironrdp-echo-v0.3.0)] - 2026-06-05 ### Build diff --git a/crates/ironrdp-echo/Cargo.toml b/crates/ironrdp-echo/Cargo.toml index e153221790..7ee574dcf8 100644 --- a/crates/ironrdp-echo/Cargo.toml +++ b/crates/ironrdp-echo/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-echo" -version = "0.3.0" +version = "0.4.0" readme = "README.md" description = "Virtual channel echo extension implementation" edition.workspace = true @@ -18,8 +18,8 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-egfx/CHANGELOG.md b/crates/ironrdp-egfx/CHANGELOG.md index 388f5edbd7..fb6d836b38 100644 --- a/crates/ironrdp-egfx/CHANGELOG.md +++ b/crates/ironrdp-egfx/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. 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). +## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-egfx-v0.2.0...ironrdp-egfx-v0.3.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency to 0.8 + +- [**breaking**] Update `ironrdp-graphics` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-egfx-v0.1.0...ironrdp-egfx-v0.2.0)] - 2026-06-05 ### Features diff --git a/crates/ironrdp-egfx/Cargo.toml b/crates/ironrdp-egfx/Cargo.toml index dce1406133..c4092470f3 100644 --- a/crates/ironrdp-egfx/Cargo.toml +++ b/crates/ironrdp-egfx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-egfx" -version = "0.2.0" +version = "0.3.0" readme = "README.md" description = "Graphics pipeline dynamic channel extension implementation" edition.workspace = true @@ -20,9 +20,9 @@ arbitrary = { version = "1", features = ["derive"], optional = true } bit_field = "0.10" bitflags = "2.11" ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public openh264 = { version = "0.9", optional = true, default-features = false } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-futures/CHANGELOG.md b/crates/ironrdp-futures/CHANGELOG.md index 6be657678f..1ffd5aa90b 100644 --- a/crates/ironrdp-futures/CHANGELOG.md +++ b/crates/ironrdp-futures/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.7.0...ironrdp-futures-v0.8.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-async` public dependency to 0.10 + + + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.6.0...ironrdp-futures-v0.7.0)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-futures/Cargo.toml b/crates/ironrdp-futures/Cargo.toml index 3c192b0120..aec911baf0 100644 --- a/crates/ironrdp-futures/Cargo.toml +++ b/crates/ironrdp-futures/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-futures" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "`Framed*` traits implementation above futures’s traits" edition.workspace = true @@ -18,7 +18,7 @@ test = false [dependencies] futures-util = { version = "0.3", features = ["io"] } # public -ironrdp-async = { path = "../ironrdp-async", version = "0.9" } # public +ironrdp-async = { path = "../ironrdp-async", version = "0.10" } # public [lints] workspace = true diff --git a/crates/ironrdp-graphics/CHANGELOG.md b/crates/ironrdp-graphics/CHANGELOG.md index 74df51fbc1..8dba87b85b 100644 --- a/crates/ironrdp-graphics/CHANGELOG.md +++ b/crates/ironrdp-graphics/CHANGELOG.md @@ -6,6 +6,20 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.8.1...ironrdp-graphics-v0.9.0)] - 2026-07-10 + +### Bug Fixes + +- Don't require CONTEXT block on every progressive frame ([#1395](https://github.com/Devolutions/IronRDP/issues/1395)) ([368fe8e68b](https://github.com/Devolutions/IronRDP/commit/368fe8e68b2d5d72da2e15dcf99469b98e965a2b)) + + Fixes progressive RemoteFX (MS-RDPEGFX) decoding by no longer requiring a CONTEXT block on every WireToSurface2 progressive frame once a codec context has already been established (keyed by codec_context_id). This aligns the decoder with real-world server behavior and the spec’s “establish once, then reference” model for progressive contexts. + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + ## [[0.8.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.8.0...ironrdp-graphics-v0.8.1)] - 2026-06-05 ### Bug Fixes diff --git a/crates/ironrdp-graphics/Cargo.toml b/crates/ironrdp-graphics/Cargo.toml index 954c9efe0f..c9ba81c255 100644 --- a/crates/ironrdp-graphics/Cargo.toml +++ b/crates/ironrdp-graphics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-graphics" -version = "0.8.1" +version = "0.9.0" readme = "README.md" description = "RDP image processing primitives" edition.workspace = true @@ -21,7 +21,7 @@ bit_field = "0.10" bitflags = "2.11" bitvec = "1.0" ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["std"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["std"] } # public byteorder = "1.5" # TODO: remove num-derive.workspace = true # TODO: remove num-traits.workspace = true # TODO: remove diff --git a/crates/ironrdp-input/CHANGELOG.md b/crates/ironrdp-input/CHANGELOG.md index b7efe4be9d..001665e2da 100644 --- a/crates/ironrdp-input/CHANGELOG.md +++ b/crates/ironrdp-input/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.6.0...ironrdp-input-v0.7.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.5.0...ironrdp-input-v0.6.0)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-input/Cargo.toml b/crates/ironrdp-input/Cargo.toml index dfdd3ceb77..e10e352055 100644 --- a/crates/ironrdp-input/Cargo.toml +++ b/crates/ironrdp-input/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-input" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "Utilities to manage and build RDP input packets" edition.workspace = true @@ -17,7 +17,7 @@ doctest = false test = false [dependencies] -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public bitvec = "1.0" smallvec = "1.15" diff --git a/crates/ironrdp-mstsgu/Cargo.toml b/crates/ironrdp-mstsgu/Cargo.toml index 3ffa8667a7..b32451ecfa 100644 --- a/crates/ironrdp-mstsgu/Cargo.toml +++ b/crates/ironrdp-mstsgu/Cargo.toml @@ -3,7 +3,6 @@ name = "ironrdp-mstsgu" version = "0.0.1" readme = "README.md" description = "Terminal Services Gateway Server Protocol" -publish = false # TODO: publish edition.workspace = true rust-version = "1.89" license.workspace = true diff --git a/crates/ironrdp-nscodec/Cargo.toml b/crates/ironrdp-nscodec/Cargo.toml index 9a3e4f5255..37df3818ca 100644 --- a/crates/ironrdp-nscodec/Cargo.toml +++ b/crates/ironrdp-nscodec/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-nscodec" -version = "0.1.0" +version = "0.2.0" readme = "README.md" description = "NSCodec ([MS-RDPNSC]) implementation for IronRDP" edition.workspace = true @@ -23,7 +23,7 @@ default = [] encoder = ["dep:ironrdp-graphics"] [dependencies] -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8", optional = true } # public when `encoder` is on +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9", optional = true } # public when `encoder` is on [lints] workspace = true diff --git a/crates/ironrdp-pdu/CHANGELOG.md b/crates/ironrdp-pdu/CHANGELOG.md index f3c710c792..5a240de371 100644 --- a/crates/ironrdp-pdu/CHANGELOG.md +++ b/crates/ironrdp-pdu/CHANGELOG.md @@ -6,6 +6,39 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.8.0...ironrdp-pdu-v0.9.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Bug Fixes + +- Set COMPRESSION_USED on the FastPath update header when compressed ([#1382](https://github.com/Devolutions/IronRDP/issues/1382)) ([3f96d0029d](https://github.com/Devolutions/IronRDP/commit/3f96d0029d37d3cee84b419bbf4d53b5519e385d)) + +- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d)) + + The error constructor helpers in several crates wrap the #[track_caller] + ironrdp_error::Error::new, but were not themselves marked + #[track_caller]. As a result, the captured location pointed at the + helper body instead of the real call site, giving misleading "@ + file:line" info in error reports. + +- Adopt MCS and RDP header utilities relocated from ironrdp-connector ([#1419](https://github.com/Devolutions/IronRDP/issues/1419)) ([5c22f86a71](https://github.com/Devolutions/IronRDP/commit/5c22f86a7150bc10c26a3be39bfaebf84c67d781)) + + Hosts the shared MCS and RDP security-header helpers previously living in ironrdp-connector's legacy modules. + +- Decode MousePdu wheel rotation as two's complement, matching encode ([#1415](https://github.com/Devolutions/IronRDP/issues/1415)) ([9b4d01b403](https://github.com/Devolutions/IronRDP/commit/9b4d01b4038ede1cdd329fd9ea47a5d241480d1d)) + + + ## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.7.0...ironrdp-pdu-v0.8.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-pdu/Cargo.toml b/crates/ironrdp-pdu/Cargo.toml index 17ba0dcf4e..e7865ad39c 100644 --- a/crates/ironrdp-pdu/Cargo.toml +++ b/crates/ironrdp-pdu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-pdu" -version = "0.8.0" +version = "0.9.0" readme = "README.md" description = "RDP PDU encoding and decoding" edition.workspace = true diff --git a/crates/ironrdp-propertyset/Cargo.toml b/crates/ironrdp-propertyset/Cargo.toml index fa81e364eb..3d2a06a6bd 100644 --- a/crates/ironrdp-propertyset/Cargo.toml +++ b/crates/ironrdp-propertyset/Cargo.toml @@ -3,7 +3,6 @@ name = "ironrdp-propertyset" version = "0.1.0" readme = "README.md" description = "A key-value store for configuration options" -publish = false # TODO: publish edition.workspace = true rust-version = "1.89" license.workspace = true diff --git a/crates/ironrdp-rdpdr-native/CHANGELOG.md b/crates/ironrdp-rdpdr-native/CHANGELOG.md index 44d0c5c699..bf89103bd6 100644 --- a/crates/ironrdp-rdpdr-native/CHANGELOG.md +++ b/crates/ironrdp-rdpdr-native/CHANGELOG.md @@ -6,6 +6,18 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.6.0...ironrdp-rdpdr-native-v0.7.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-rdpdr` public dependency to 0.7 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.5.0...ironrdp-rdpdr-native-v0.6.0)] - 2026-05-27 ### Bug Fixes diff --git a/crates/ironrdp-rdpdr-native/Cargo.toml b/crates/ironrdp-rdpdr-native/Cargo.toml index e81db959d6..0a16fe5b63 100644 --- a/crates/ironrdp-rdpdr-native/Cargo.toml +++ b/crates/ironrdp-rdpdr-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpdr-native" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "Native RDPDR static channel backend implementations for IronRDP" edition.workspace = true @@ -18,8 +18,8 @@ test = false [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7" } # public nix = { version = "0.31", features = ["fs", "dir"] } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-rdpdr/CHANGELOG.md b/crates/ironrdp-rdpdr/CHANGELOG.md index c0ffb67ae4..ec466f8009 100644 --- a/crates/ironrdp-rdpdr/CHANGELOG.md +++ b/crates/ironrdp-rdpdr/CHANGELOG.md @@ -6,6 +6,16 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.6.0...ironrdp-rdpdr-v0.7.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.5.0...ironrdp-rdpdr-v0.6.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-rdpdr/Cargo.toml b/crates/ironrdp-rdpdr/Cargo.toml index 55ad161bd3..b6dfa959da 100644 --- a/crates/ironrdp-rdpdr/Cargo.toml +++ b/crates/ironrdp-rdpdr/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpdr" -version = "0.6.0" +version = "0.7.0" readme = "README.md" description = "RDPDR channel implementation." edition.workspace = true @@ -19,8 +19,8 @@ test = false [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } bitflags = "2.11" diff --git a/crates/ironrdp-rdpeusb/Cargo.toml b/crates/ironrdp-rdpeusb/Cargo.toml index e235fc1852..dbc7c54778 100644 --- a/crates/ironrdp-rdpeusb/Cargo.toml +++ b/crates/ironrdp-rdpeusb/Cargo.toml @@ -22,8 +22,8 @@ std = [] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["alloc"] } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public ironrdp-str = { path = "../ironrdp-str", version = "0.1" } [lints] diff --git a/crates/ironrdp-rdpfile/Cargo.toml b/crates/ironrdp-rdpfile/Cargo.toml index f055220a6c..6e664a0142 100644 --- a/crates/ironrdp-rdpfile/Cargo.toml +++ b/crates/ironrdp-rdpfile/Cargo.toml @@ -3,7 +3,6 @@ name = "ironrdp-rdpfile" version = "0.1.0" readme = "README.md" description = "Parser and writer for .RDP file format" -publish = false # TODO: publish edition.workspace = true rust-version = "1.89" license.workspace = true diff --git a/crates/ironrdp-rdpsnd-native/CHANGELOG.md b/crates/ironrdp-rdpsnd-native/CHANGELOG.md index 8c0601c577..88bab83e23 100644 --- a/crates/ironrdp-rdpsnd-native/CHANGELOG.md +++ b/crates/ironrdp-rdpsnd-native/CHANGELOG.md @@ -6,6 +6,22 @@ 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). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.6.0...ironrdp-rdpsnd-native-v0.7.0)] - 2026-07-10 + +### Bug Fixes + +- Lower verbosity of routine logs in library crates ([c36032f91b](https://github.com/Devolutions/IronRDP/commit/c36032f91b27390a2cd34bfb300cfbe099d847a9)) + + Library crates should not emit info! for routine, repeating operations; + that floods the default logs of the final consumer, which owns the + verbosity decision. Reserve info! for rare connection/session lifecycle + milestones, debug! for significant one-off events, and trace! for the + fine-grained detail only needed when nothing else explains a problem. + +- [**breaking**] Replace anyhow with typed RdpsndNativeError ([#1277](https://github.com/Devolutions/IronRDP/issues/1277)) ([37483ebd9b](https://github.com/Devolutions/IronRDP/commit/37483ebd9b7628325666f434e1679e7f885fb289)) + + + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.5.0...ironrdp-rdpsnd-native-v0.6.0)] - 2026-05-27 ### Bug Fixes diff --git a/crates/ironrdp-rdpsnd-native/Cargo.toml b/crates/ironrdp-rdpsnd-native/Cargo.toml index ec7156aca9..84688e870a 100644 --- a/crates/ironrdp-rdpsnd-native/Cargo.toml +++ b/crates/ironrdp-rdpsnd-native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpsnd-native" -version = "0.6.0" +version = "0.7.0" description = "Native RDPSND static channel backend implementations for IronRDP" edition.workspace = true rust-version = "1.89" @@ -23,7 +23,7 @@ opus = ["dep:opus2", "dep:bytemuck"] bytemuck = { version = "1.24", optional = true } cpal = "0.17" ironrdp-error = { path = "../ironrdp-error", version = "0.2", features = ["std"] } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8" } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9" } # public opus2 = { version = "0.4", optional = true, features = ["bundled"] } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-rdpsnd/CHANGELOG.md b/crates/ironrdp-rdpsnd/CHANGELOG.md index a60c4d09d9..0c3cb06b0d 100644 --- a/crates/ironrdp-rdpsnd/CHANGELOG.md +++ b/crates/ironrdp-rdpsnd/CHANGELOG.md @@ -6,6 +6,21 @@ 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). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.8.1...ironrdp-rdpsnd-v0.9.0)] - 2026-07-10 + +### Features + +- [**breaking**] Misuse-resistant format negotiation for RdpsndServerHandler ([#1359](https://github.com/Devolutions/IronRDP/issues/1359)) ([2d3bdef1a7](https://github.com/Devolutions/IronRDP/commit/2d3bdef1a7167d2acdc478a92917cbb2f018960b)) + + Move the negotiation into the crate and split selection from lifecycle: + + ```rust + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>; + fn start(&mut self, format: &NegotiatedFormat); + ``` + + + ## [[0.8.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.8.0...ironrdp-rdpsnd-v0.8.1)] - 2026-06-05 ### Documentation diff --git a/crates/ironrdp-rdpsnd/Cargo.toml b/crates/ironrdp-rdpsnd/Cargo.toml index bb5aa59374..84d1fd15b5 100644 --- a/crates/ironrdp-rdpsnd/Cargo.toml +++ b/crates/ironrdp-rdpsnd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-rdpsnd" -version = "0.8.1" +version = "0.9.0" readme = "README.md" description = "RDPSND static channel for audio output implemented as described in MS-RDPEA" edition.workspace = true @@ -28,9 +28,9 @@ __test = ["dep:visibility"] [dependencies] bitflags = "2.11" tracing = { version = "0.1", features = ["log"] } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["alloc"] } # public visibility = { version = "0.1", optional = true } [lints] diff --git a/crates/ironrdp-server/CHANGELOG.md b/crates/ironrdp-server/CHANGELOG.md index 9de46c3d57..6bc9421f45 100644 --- a/crates/ironrdp-server/CHANGELOG.md +++ b/crates/ironrdp-server/CHANGELOG.md @@ -6,6 +6,42 @@ 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). +## [[0.13.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.12.0...ironrdp-server-v0.13.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Features + +- Expose NetworkAutoDetect RTT via a shared handle ([#1346](https://github.com/Devolutions/IronRDP/issues/1346)) ([481ea5d161](https://github.com/Devolutions/IronRDP/commit/481ea5d161964b06a08f0b1ace0a1efd11773b4a)) + + Exposes the server’s NetworkAutoDetect RTT measurement via a shared Arc handle so display backends can read a fresh RTT value even after run() takes ownership of the server. + +- Dispatch initiate_file_copy via ClipboardMessage ([#1388](https://github.com/Devolutions/IronRDP/issues/1388)) ([b6325f9ea6](https://github.com/Devolutions/IronRDP/commit/b6325f9ea6900a84643b4415f9ebc7b1010cf3cd)) + + Extends the CLIPRDR backend-facing API to properly support offering clipboard file lists (so later FileContentsRequests can be serviced) by introducing ClipboardMessage::SendInitiateFileCopy(Vec) and wiring it through the in-tree ClipboardMessage dispatchers. + +- Honor the client-requested desktop size ([#1373](https://github.com/Devolutions/IronRDP/issues/1373)) ([d471bd066f](https://github.com/Devolutions/IronRDP/commit/d471bd066f303df22f4767801fd97ecdbf527869)) + + Adds an opt-in server/acceptor knob to negotiate the RDP session desktop size using the client’s originally requested resolution (from GCC Client Core Data) so the server can start at the client’s native size without a Deactivation–Reactivation resize round trip. + +- Accept connections with TLS terminated at a lower layer ([#1281](https://github.com/Devolutions/IronRDP/issues/1281)) ([18bf75c7b3](https://github.com/Devolutions/IronRDP/commit/18bf75c7b3442881b42ee79b5f530ca97ab391ed)) + + Adds a way to run a single RDP connection over a byte stream whose + confidentiality is already provided by the embedder's transport, rather + than having ironrdp-server perform the inner TLS handshake itself when + X.224 selects PROTOCOL_SSL. + + + ## [[0.12.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.11.0...ironrdp-server-v0.12.0)] - 2026-06-05 ### Features diff --git a/crates/ironrdp-server/Cargo.toml b/crates/ironrdp-server/Cargo.toml index c01b606689..ad7b14c122 100644 --- a/crates/ironrdp-server/Cargo.toml +++ b/crates/ironrdp-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-server" -version = "0.12.0" +version = "0.13.0" readme = "README.md" description = "Extendable skeleton for implementing custom RDP servers" edition.workspace = true @@ -37,21 +37,21 @@ anyhow = "1.0" tokio = { version = "1", features = ["net", "macros", "sync", "rt"] } # public tokio-rustls = "0.26" # public async-trait = "0.1" -ironrdp-async = { path = "../ironrdp-async", version = "0.9" } -ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.7" } +ironrdp-async = { path = "../ironrdp-async", version = "0.10" } +ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.8" } ironrdp-core = { path = "../ironrdp-core", version = "0.2" } -ironrdp-egfx = { path = "../ironrdp-egfx", version = "0.2", optional = true } -ironrdp-nscodec = { path = "../ironrdp-nscodec", version = "0.1", optional = true, features = ["encoder"] } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6" } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7" } # public -ironrdp-echo = { path = "../ironrdp-echo", version = "0.3" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public -ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.9", features = ["reqwest"] } -ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.9" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8" } # public +ironrdp-egfx = { path = "../ironrdp-egfx", version = "0.3", optional = true } +ironrdp-nscodec = { path = "../ironrdp-nscodec", version = "0.2", optional = true, features = ["encoder"] } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.7" } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8" } # public +ironrdp-echo = { path = "../ironrdp-echo", version = "0.4" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.10", features = ["reqwest"] } +ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.10" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9" } # public tracing = { version = "0.1", features = ["log"] } x509-cert = { version = "0.2", optional = true } rustls-pemfile = { version = "2.2", optional = true } diff --git a/crates/ironrdp-session/CHANGELOG.md b/crates/ironrdp-session/CHANGELOG.md index e20654c35c..240f41962d 100644 --- a/crates/ironrdp-session/CHANGELOG.md +++ b/crates/ironrdp-session/CHANGELOG.md @@ -6,6 +6,44 @@ 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). +## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.10.0...ironrdp-session-v0.11.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Bug Fixes + +- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d)) + + The error constructor helpers in several crates wrap the #[track_caller] + ironrdp_error::Error::new, but were not themselves marked + #[track_caller]. As a result, the captured location pointed at the + helper body instead of the real call site, giving misleading "@ + file:line" info in error reports. + +- Reduce dependency on ironrdp-connector ([#1419](https://github.com/Devolutions/IronRDP/issues/1419)) ([5c22f86a71](https://github.com/Devolutions/IronRDP/commit/5c22f86a7150bc10c26a3be39bfaebf84c67d781)) + + Drops session's reliance on ironrdp-connector legacy helpers, now sourced from ironrdp-pdu. + +- [**breaking**] Remove ironrdp-connector dependency ([#1435](https://github.com/Devolutions/IronRDP/issues/1435)) ([c6a0286dcb](https://github.com/Devolutions/IronRDP/commit/c6a0286dcb49d9ac54c65c4f9325b41e05d541b8)) + + Removes the last ironrdp-connector coupling from ironrdp-session by + turning Deactivate-All handling into a bare signal and shifting ownership + of the Deactivation-Reactivation activation sequence back to each consumer. + It introduces a ConnectionActivationFactory (fresh sequence per reactivation) + and an ActiveStageBuilder so session construction no longer depends on + ConnectionResult. + + + ## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.9.0...ironrdp-session-v0.10.0)] - 2026-06-05 ### Bug Fixes diff --git a/crates/ironrdp-session/Cargo.toml b/crates/ironrdp-session/Cargo.toml index a62d30e18f..dd29ab906b 100644 --- a/crates/ironrdp-session/Cargo.toml +++ b/crates/ironrdp-session/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-session" -version = "0.10.0" +version = "0.11.0" readme = "README.md" description = "State machines to drive an RDP session" edition.workspace = true @@ -24,12 +24,12 @@ qoiz = ["dep:zstd-safe", "qoi"] [dependencies] ironrdp-bulk = { path = "../ironrdp-bulk", version = "0.1" } ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["std"] } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7" } +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["std"] } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8" } tracing = { version = "0.1", features = ["log"] } qoicoubeh = { version = "0.5", optional = true } zstd-safe = { version = "7.2", optional = true, features = ["std"] } diff --git a/crates/ironrdp-svc/CHANGELOG.md b/crates/ironrdp-svc/CHANGELOG.md index c5cd24820f..4aa33691e9 100644 --- a/crates/ironrdp-svc/CHANGELOG.md +++ b/crates/ironrdp-svc/CHANGELOG.md @@ -6,6 +6,14 @@ 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). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.7.0...ironrdp-svc-v0.8.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.6.0...ironrdp-svc-v0.7.0)] - 2026-05-27 ### Features diff --git a/crates/ironrdp-svc/Cargo.toml b/crates/ironrdp-svc/Cargo.toml index b17dcce05d..88ba8e9546 100644 --- a/crates/ironrdp-svc/Cargo.toml +++ b/crates/ironrdp-svc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-svc" -version = "0.7.0" +version = "0.8.0" readme = "README.md" description = "IronRDP traits to implement RDP static virtual channels" edition.workspace = true @@ -22,7 +22,7 @@ std = [] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", features = ["alloc", "std"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["alloc", "std"] } # public bitflags = "2.11" [lints] diff --git a/crates/ironrdp-tls/CHANGELOG.md b/crates/ironrdp-tls/CHANGELOG.md index 9d3124ab6a..7d50b6b2a8 100644 --- a/crates/ironrdp-tls/CHANGELOG.md +++ b/crates/ironrdp-tls/CHANGELOG.md @@ -6,6 +6,25 @@ 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). +## [[0.2.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.2.1...ironrdp-tls-v0.2.2)] - 2026-07-10 + +### Features + +- Expose negotiated TLS version and cipher suite ([#1384](https://github.com/Devolutions/IronRDP/issues/1384)) ([8f76260ea7](https://github.com/Devolutions/IronRDP/commit/8f76260ea753f546a577ad7a1176a5740adc94cf)) + + Adds a backend-neutral way to query the TLS parameters negotiated for an established + ironrdp-tls::TlsStream, enabling downstream diagnostic tooling to report the negotiated + protocol version and cipher suite alongside the existing certificate information. + +- Gate native backends behind Cargo features ([#1338](https://github.com/Devolutions/IronRDP/issues/1338)) ([f7e6106e0f](https://github.com/Devolutions/IronRDP/commit/f7e6106e0f293c1e0f8129be82aa2d86737ba92a)) + + +- Make the rustls crypto provider selectable ([#1387](https://github.com/Devolutions/IronRDP/issues/1387)) ([d767d99032](https://github.com/Devolutions/IronRDP/commit/d767d990325448bf3385974da7ea9b6dcc477673)) + + Makes the ironrdp-tls rustls backend’s crypto provider selectable at compile time by restructuring Cargo features, avoiding forcing a single provider onto downstreams via tokio-rustls default features. + + + ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.2.0...ironrdp-tls-v0.2.1)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-tls/Cargo.toml b/crates/ironrdp-tls/Cargo.toml index 8ad4440edb..ddd1eba020 100644 --- a/crates/ironrdp-tls/Cargo.toml +++ b/crates/ironrdp-tls/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-tls" -version = "0.2.1" +version = "0.2.2" readme = "README.md" description = "TLS boilerplate common with most IronRDP clients" edition.workspace = true diff --git a/crates/ironrdp-tokio/CHANGELOG.md b/crates/ironrdp-tokio/CHANGELOG.md index b1b6a90704..893bfda462 100644 --- a/crates/ironrdp-tokio/CHANGELOG.md +++ b/crates/ironrdp-tokio/CHANGELOG.md @@ -6,6 +6,16 @@ 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). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.9.0...ironrdp-tokio-v0.10.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-async` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + ## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.8.0...ironrdp-tokio-v0.9.0)] - 2026-05-27 ### Build diff --git a/crates/ironrdp-tokio/Cargo.toml b/crates/ironrdp-tokio/Cargo.toml index 042ae76057..8dc421c475 100644 --- a/crates/ironrdp-tokio/Cargo.toml +++ b/crates/ironrdp-tokio/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp-tokio" -version = "0.9.0" +version = "0.10.0" readme = "README.md" description = "`Framed*` traits implementation above Tokio’s traits" edition.workspace = true @@ -23,8 +23,8 @@ reqwest-rustls-ring = ["reqwest", "reqwest?/rustls-tls-webpki-roots"] reqwest-native-tls = ["reqwest", "reqwest?/native-tls"] [dependencies] -ironrdp-async = { path = "../ironrdp-async", version = "0.9" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.9", optional = true } +ironrdp-async = { path = "../ironrdp-async", version = "0.10" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10", optional = true } tokio = { version = "1", features = ["io-util"] } reqwest = { version = "0.12", default-features = false, features = ["http2", "system-proxy"], optional = true } url = { version = "2.5", optional = true } diff --git a/crates/ironrdp-viewer/Cargo.toml b/crates/ironrdp-viewer/Cargo.toml index 18ac91b171..96dc099e25 100644 --- a/crates/ironrdp-viewer/Cargo.toml +++ b/crates/ironrdp-viewer/Cargo.toml @@ -12,9 +12,6 @@ keywords.workspace = true categories.workspace = true default-run = "ironrdp-viewer" -# Not publishing for now. -publish = false - [lib] doctest = false test = false @@ -31,10 +28,10 @@ qoi = ["ironrdp/qoi"] qoiz = ["ironrdp/qoiz"] [dependencies] -ironrdp = { path = "../ironrdp", features = ["connector", "cliprdr", "input", "pdu", "client", "client-all"] } -ironrdp-cfg = { path = "../ironrdp-cfg" } -ironrdp-propertyset = { path = "../ironrdp-propertyset" } -ironrdp-rdpfile = { path = "../ironrdp-rdpfile" } +ironrdp = { path = "../ironrdp", version = "0.17", features = ["connector", "cliprdr", "input", "pdu", "client", "client-all"] } +ironrdp-cfg = { path = "../ironrdp-cfg", version = "0.1" } +ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } +ironrdp-rdpfile = { path = "../ironrdp-rdpfile", version = "0.1" } # Windowing and rendering winit = { version = "0.30", features = ["rwh_06"] } diff --git a/crates/ironrdp/CHANGELOG.md b/crates/ironrdp/CHANGELOG.md index 3186d85fe1..04b8244a06 100644 --- a/crates/ironrdp/CHANGELOG.md +++ b/crates/ironrdp/CHANGELOG.md @@ -6,6 +6,51 @@ 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). +## [[0.17.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.16.0...ironrdp-v0.17.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Features + +- Gate native backends behind Cargo features ([#1338](https://github.com/Devolutions/IronRDP/issues/1338)) ([f7e6106e0f](https://github.com/Devolutions/IronRDP/commit/f7e6106e0f293c1e0f8129be82aa2d86737ba92a)) + + - Added: client, client-all, client-sound, client-clipboard, + client-rdpdr, client-smartcard, client-gateway, + client-dvc-pipe-proxy, client-dvc-com-plugin, and + top-level rustls / native-tls (forwarded to ironrdp-client) + - Modified: qoi, qoiz now also gate ironrdp-client's codec + +- [**breaking**] Misuse-resistant format negotiation for RdpsndServerHandler ([#1359](https://github.com/Devolutions/IronRDP/issues/1359)) ([2d3bdef1a7](https://github.com/Devolutions/IronRDP/commit/2d3bdef1a7167d2acdc478a92917cbb2f018960b)) + + Move the negotiation into the crate and split selection from lifecycle: + + ```rust + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>; + fn start(&mut self, format: &NegotiatedFormat); + ``` + +### Bug Fixes + +- [**breaking**] Remove ironrdp-connector dependency ([#1435](https://github.com/Devolutions/IronRDP/issues/1435)) ([c6a0286dcb](https://github.com/Devolutions/IronRDP/commit/c6a0286dcb49d9ac54c65c4f9325b41e05d541b8)) + + Removes the last ironrdp-connector coupling from ironrdp-session by + turning Deactivate-All handling into a bare signal and shifting ownership + of the Deactivation-Reactivation activation sequence back to each consumer. + It introduces a ConnectionActivationFactory (fresh sequence per reactivation) + and an ActiveStageBuilder so session construction no longer depends on + ConnectionResult. + + + ## [[0.16.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.15.0...ironrdp-v0.16.0)] - 2026-06-05 ### Build diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index 856f7827a5..3f6cb1ded6 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironrdp" -version = "0.16.0" +version = "0.17.0" readme = "README.md" description = "A meta crate re-exporting IronRDP crates for convenience" edition.workspace = true @@ -56,26 +56,26 @@ __bench = ["ironrdp-server/__bench"] [dependencies] ironrdp-core = { path = "../ironrdp-core", version = "0.2", optional = true } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8", optional = true } # public -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6", optional = true } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.9", optional = true } # public -ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.9", optional = true } # public -ironrdp-session = { path = "../ironrdp-session", version = "0.10", optional = true } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8", optional = true } # public -ironrdp-input = { path = "../ironrdp-input", version = "0.6", optional = true } # public -ironrdp-server = { path = "../ironrdp-server", version = "0.12", optional = true, features = ["helper"] } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.7", optional = true } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7", optional = true } # public -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6", optional = true } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8", optional = true } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7", optional = true } # public -ironrdp-echo = { path = "../ironrdp-echo", version = "0.3", optional = true } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", optional = true } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.7", optional = true } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10", optional = true } # public +ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.10", optional = true } # public +ironrdp-session = { path = "../ironrdp-session", version = "0.11", optional = true } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9", optional = true } # public +ironrdp-input = { path = "../ironrdp-input", version = "0.7", optional = true } # public +ironrdp-server = { path = "../ironrdp-server", version = "0.13", optional = true, features = ["helper"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8", optional = true } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8", optional = true } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7", optional = true } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9", optional = true } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8", optional = true } # public +ironrdp-echo = { path = "../ironrdp-echo", version = "0.4", optional = true } # public ironrdp-mstsgu = { path = "../ironrdp-mstsgu", version = "0.0.1", optional = true } # public ironrdp-client = { path = "../ironrdp-client", version = "0.1", optional = true } # public [dev-dependencies] -ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.9" } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6" } +ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.10" } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.7" } anyhow = "1" async-trait = "0.1" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 1780d536f5..6e4e1344d0 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -75,15 +75,15 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -108,9 +108,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.63" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -275,14 +275,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", ] [[package]] @@ -291,7 +290,7 @@ version = "0.1.1" [[package]] name = "ironrdp-cliprdr" -version = "0.6.0" +version = "0.7.0" dependencies = [ "bitflags", "ironrdp-core", @@ -310,14 +309,14 @@ dependencies = [ [[package]] name = "ironrdp-core" -version = "0.2.0" +version = "0.2.1" dependencies = [ "ironrdp-error", ] [[package]] name = "ironrdp-displaycontrol" -version = "0.7.0" +version = "0.8.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -328,7 +327,7 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -338,7 +337,7 @@ dependencies = [ [[package]] name = "ironrdp-egfx" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bit_field", "bitflags", @@ -381,7 +380,7 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.8.1" +version = "0.9.0" dependencies = [ "bit_field", "bitflags", @@ -396,7 +395,7 @@ dependencies = [ [[package]] name = "ironrdp-pdu" -version = "0.8.0" +version = "0.9.0" dependencies = [ "bit_field", "bitflags", @@ -417,7 +416,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr" -version = "0.6.0" +version = "0.7.0" dependencies = [ "bitflags", "ironrdp-core", @@ -429,7 +428,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.8.1" +version = "0.9.0" dependencies = [ "bitflags", "ironrdp-core", @@ -440,7 +439,7 @@ dependencies = [ [[package]] name = "ironrdp-svc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "bitflags", "ironrdp-core", @@ -449,9 +448,9 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ "getrandom", "libc", @@ -475,9 +474,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "md-5" @@ -491,9 +490,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal-lexical" @@ -523,9 +522,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -606,18 +605,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "radium" @@ -669,9 +668,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -786,21 +785,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "wyz" version = "0.5.1" @@ -824,27 +808,27 @@ dependencies = [ [[package]] name = "yuv" -version = "0.8.14" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89c90da4fb561f9750984de2c5e7f0ba01035d2eb29d69a7f375b1caef37fdf4" +checksum = "5d85a782d94ee43f078bcfd6fa82d4e6a5b2d1cfbbad168e4df5a9f7b39ef48c" dependencies = [ "num-traits", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/release-plz.toml b/release-plz.toml index 4f3a3395e6..fc831fff74 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -12,18 +12,22 @@ release_commits = "^(feat|docs|fix|build|perf)" [[package]] name = "ironrdp-agent" git_release_enable = true -publish = false # TODO: enable publishing when ready. - [[package]] name = "ironrdp-viewer" git_release_enable = true -publish = false # TODO: enable publishing when ready. -# ironrdp-tls does not compile if no backend is specified. -# rustls is the most common backend, so we let cargo publish check with it. +# ironrdp-tls does not compile if no backend is specified, and the following crates depend on it +# without enabling a backend by default. rustls is the most common backend, so we let cargo publish +# check with it. [[package]] name = "ironrdp-tls" publish_features = ["rustls"] +[[package]] +name = "ironrdp-client" +publish_features = ["rustls"] +[[package]] +name = "ironrdp-mstsgu" +publish_features = ["rustls"] # *-native crates may have all kinds of system requirements depending on the platform. # We can only check for the current platform when cargo publish is invoked, all the others are effectively unverified. From c0a29813bfbdf5db54e8f0dbb7c4ad12a3d83c16 Mon Sep 17 00:00:00 2001 From: devolutionsbot <31221910+devolutionsbot@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:59:01 -0400 Subject: [PATCH 322/325] chore(release): clean up (#1440) --- .github/workflows/release-binaries.yml | 20 ++++++++++++++++---- Cargo.lock | 8 ++++---- crates/ironrdp-agent/CHANGELOG.md | 11 +++++++++++ crates/ironrdp-cfg/CHANGELOG.md | 11 +++++++++++ crates/ironrdp-client/CHANGELOG.md | 11 +++++++++++ crates/ironrdp-mstsgu/CHANGELOG.md | 11 +++++++++++ crates/ironrdp-propertyset/CHANGELOG.md | 11 +++++++++++ crates/ironrdp-rdpfile/CHANGELOG.md | 11 +++++++++++ crates/ironrdp-viewer/CHANGELOG.md | 11 +++++++++++ fuzz/Cargo.lock | 4 ++-- 10 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 crates/ironrdp-agent/CHANGELOG.md create mode 100644 crates/ironrdp-cfg/CHANGELOG.md create mode 100644 crates/ironrdp-client/CHANGELOG.md create mode 100644 crates/ironrdp-mstsgu/CHANGELOG.md create mode 100644 crates/ironrdp-propertyset/CHANGELOG.md create mode 100644 crates/ironrdp-rdpfile/CHANGELOG.md create mode 100644 crates/ironrdp-viewer/CHANGELOG.md diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 182bb95288..230013bc90 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -3,6 +3,16 @@ name: Release binaries on: release: types: [published] + # Manual re-run path: when the release-triggered run fails (e.g. a bug in this + # workflow), a UI re-run reuses the workflow file at the tag's commit, so a fix + # merged to the default branch would NOT take effect. Dispatching manually runs + # the fixed workflow from the default branch against an existing release tag. + workflow_dispatch: + inputs: + tag: + description: "Release tag to (re)build binaries for (e.g. ironrdp-agent-v0.1.0)" + required: true + type: string permissions: contents: write @@ -26,7 +36,7 @@ jobs: - name: Select released CLI id: select env: - TAG_NAME: ${{ github.event.release.tag_name }} + TAG_NAME: ${{ github.event.release.tag_name || inputs.tag }} run: | case "$TAG_NAME" in ironrdp-agent-v*) @@ -80,7 +90,7 @@ jobs: - name: Checkout release tag uses: actions/checkout@v6 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.event.release.tag_name || inputs.tag }} - name: Install build dependencies uses: ./.github/actions/install-build-deps @@ -158,14 +168,16 @@ jobs: - name: Upload release assets env: GH_TOKEN: ${{ github.token }} - TAG_NAME: ${{ github.event.release.tag_name }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ github.event.release.tag_name || inputs.tag }} run: gh release upload "$TAG_NAME" release-assets/* --clobber - name: Update release notes with install instructions shell: pwsh env: GH_TOKEN: ${{ github.token }} - TAG_NAME: ${{ github.event.release.tag_name }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ github.event.release.tag_name || inputs.tag }} PACKAGE: ${{ needs.select-package.outputs.package }} VERSION: ${{ needs.select-package.outputs.version }} run: | diff --git a/Cargo.lock b/Cargo.lock index 276017db32..0c13422075 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2753,7 +2753,7 @@ dependencies = [ "num-integer", "num-traits", "pkcs1 0.7.5", - "sha1 0.10.6", + "sha1 0.10.7", "tap", "x509-cert", ] @@ -5319,9 +5319,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -6213,7 +6213,7 @@ dependencies = [ "rand 0.9.4", "rustls", "rustls-pki-types", - "sha1 0.10.6", + "sha1 0.10.7", "thiserror 2.0.18", ] diff --git a/crates/ironrdp-agent/CHANGELOG.md b/crates/ironrdp-agent/CHANGELOG.md new file mode 100644 index 0000000000..46674e619c --- /dev/null +++ b/crates/ironrdp-agent/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-agent-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-cfg/CHANGELOG.md b/crates/ironrdp-cfg/CHANGELOG.md new file mode 100644 index 0000000000..62378530ec --- /dev/null +++ b/crates/ironrdp-cfg/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-cfg-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-client/CHANGELOG.md b/crates/ironrdp-client/CHANGELOG.md new file mode 100644 index 0000000000..cdbc79f701 --- /dev/null +++ b/crates/ironrdp-client/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-client-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-mstsgu/CHANGELOG.md b/crates/ironrdp-mstsgu/CHANGELOG.md new file mode 100644 index 0000000000..1c6f956786 --- /dev/null +++ b/crates/ironrdp-mstsgu/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.0.1](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-mstsgu-v0.0.1)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-propertyset/CHANGELOG.md b/crates/ironrdp-propertyset/CHANGELOG.md new file mode 100644 index 0000000000..7831314e13 --- /dev/null +++ b/crates/ironrdp-propertyset/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-propertyset-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-rdpfile/CHANGELOG.md b/crates/ironrdp-rdpfile/CHANGELOG.md new file mode 100644 index 0000000000..9b929a7aed --- /dev/null +++ b/crates/ironrdp-rdpfile/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-rdpfile-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-viewer/CHANGELOG.md b/crates/ironrdp-viewer/CHANGELOG.md new file mode 100644 index 0000000000..d146d9388d --- /dev/null +++ b/crates/ironrdp-viewer/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-viewer-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 6e4e1344d0..988a0de636 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -635,9 +635,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures", From bdcc0ceec3aaa19441917db02c36cd3be2f58465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Cortier?= <3809077+CBenoit@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:47:12 +0900 Subject: [PATCH 323/325] feat(core): add NonEmpty (#1444) Add a `NonEmpty` collection guaranteeing at least one element. The first element (head) is stored inline, so a single-element `NonEmpty` performs no heap allocation, and `first()` is infallible while `len()` returns a `NonZeroUsize`, and callers never branch on an "is it empty?" case. --- crates/ironrdp-core/src/lib.rs | 4 ++ crates/ironrdp-core/src/non_empty.rs | 87 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 crates/ironrdp-core/src/non_empty.rs diff --git a/crates/ironrdp-core/src/lib.rs b/crates/ironrdp-core/src/lib.rs index 95db742f71..bbef57bdda 100644 --- a/crates/ironrdp-core/src/lib.rs +++ b/crates/ironrdp-core/src/lib.rs @@ -16,6 +16,8 @@ mod decode; mod encode; mod error; mod into_owned; +#[cfg(feature = "alloc")] +mod non_empty; mod padding; #[cfg(feature = "alloc")] mod write_buf; @@ -43,6 +45,8 @@ pub use self::error::{ other_err_with_source, unexpected_message_type_err, unsupported_value_err, unsupported_version_err, }; pub use self::into_owned::IntoOwned; +#[cfg(feature = "alloc")] +pub use self::non_empty::NonEmpty; pub use self::padding::{read_padding, write_padding}; #[cfg(feature = "alloc")] pub use self::write_buf::WriteBuf; diff --git a/crates/ironrdp-core/src/non_empty.rs b/crates/ironrdp-core/src/non_empty.rs new file mode 100644 index 0000000000..48a93e2f61 --- /dev/null +++ b/crates/ironrdp-core/src/non_empty.rs @@ -0,0 +1,87 @@ +use alloc::vec::Vec; +use core::num::NonZeroUsize; + +/// A vector-like collection that is guaranteed to contain at least one element. +/// +/// The first element (the [head](NonEmpty::first)) is stored inline, so a single-element +/// `NonEmpty` performs no heap allocation. Additional elements are kept in a growable tail. +/// +/// Because the collection can never be empty, [`first`](NonEmpty::first) is infallible and +/// [`len`](NonEmpty::len) returns a [`NonZeroUsize`]: callers never have to branch on an +/// "is it empty?" case. +/// +/// Elements are kept in insertion order: the head is the first inserted element. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonEmpty { + head: T, + tail: Vec, +} + +impl NonEmpty { + /// Creates a new collection containing a single element. + /// + /// No allocation is performed until a second element is [pushed](NonEmpty::push). + #[must_use] + pub const fn new(head: T) -> Self { + Self { head, tail: Vec::new() } + } + + /// Appends an element after the existing ones. + pub fn push(&mut self, value: T) { + self.tail.push(value); + } + + /// Returns a reference to the first element. + /// + /// This never fails: the collection always contains at least one element. + #[must_use] + pub const fn first(&self) -> &T { + &self.head + } + + /// Returns the number of elements, which is always at least one. + #[must_use] + pub fn len(&self) -> NonZeroUsize { + // INVARIANT: the head always counts for one, so the total is never zero. + NonZeroUsize::MIN.saturating_add(self.tail.len()) + } + + /// Returns an iterator over the elements, in insertion order, starting with the head. + pub fn iter(&self) -> impl Iterator { + core::iter::once(&self.head).chain(self.tail.iter()) + } + + /// Consumes the collection, keeping only the elements for which `predicate` returns `true`. + /// + /// Returns `None` when no element is kept (a `NonEmpty` cannot represent an empty result). + #[must_use] + pub fn filter(self, mut predicate: F) -> Option + where + F: FnMut(&T) -> bool, + { + let mut kept = core::iter::once(self.head) + .chain(self.tail) + .filter(|value| predicate(value)); + let head = kept.next()?; + let tail = kept.collect(); + Some(Self { head, tail }) + } +} + +impl IntoIterator for NonEmpty { + type Item = T; + type IntoIter = core::iter::Chain, alloc::vec::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + core::iter::once(self.head).chain(self.tail) + } +} + +impl<'a, T> IntoIterator for &'a NonEmpty { + type Item = &'a T; + type IntoIter = core::iter::Chain, core::slice::Iter<'a, T>>; + + fn into_iter(self) -> Self::IntoIter { + core::iter::once(&self.head).chain(self.tail.iter()) + } +} From 76ad1459bfa4e96c0795714db2dd0dccd456a87d Mon Sep 17 00:00:00 2001 From: uchouT Date: Mon, 13 Jul 2026 16:30:54 +0800 Subject: [PATCH 324/325] test(rdpeusb): add client/server I/O sequence coverage (#1406) --- crates/ironrdp-rdpeusb/src/client.rs | 22 +- .../tests/rdpeusb/client.rs | 36 +- .../tests/rdpeusb/io/mod.rs | 355 ++++++++++++++++++ .../tests/rdpeusb/io/requests.rs | 217 +++++++++++ .../tests/rdpeusb/io/transfers.rs | 289 ++++++++++++++ .../tests/rdpeusb/mod.rs | 22 +- .../tests/rdpeusb/server.rs | 226 +++++++++++ 7 files changed, 1127 insertions(+), 40 deletions(-) create mode 100644 crates/ironrdp-testsuite-core/tests/rdpeusb/io/mod.rs create mode 100644 crates/ironrdp-testsuite-core/tests/rdpeusb/io/requests.rs create mode 100644 crates/ironrdp-testsuite-core/tests/rdpeusb/io/transfers.rs create mode 100644 crates/ironrdp-testsuite-core/tests/rdpeusb/server.rs diff --git a/crates/ironrdp-rdpeusb/src/client.rs b/crates/ironrdp-rdpeusb/src/client.rs index b5806a02af..586156d297 100644 --- a/crates/ironrdp-rdpeusb/src/client.rs +++ b/crates/ironrdp-rdpeusb/src/client.rs @@ -215,7 +215,7 @@ pub trait UrbdrcDeviceBackend: Send { /// the message MUST match the RequestId in the QUERY_DEVICE_TEXT message. /// /// [3.3.5.3.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/834f56cc-cfed-4649-8952-0b6486638c28 - fn query_device_text(&mut self, channel_id: u32, text_type: u32, locale_id: u32) -> PduResult>; + fn query_device_text(&mut self, channel_id: u32, text_type: u32, locale_id: u32) -> PduResult; /// Process an `IoControl` request. /// @@ -579,19 +579,15 @@ impl DvcProcessor for UrbdrcDeviceClient { if !self.ready_for_io || dev_text_pdu.udev_iface != self.udev_iface { return Ok(Vec::new()); } - if let Some(device_text) = + let device_text = self.backend - .query_device_text(channel_id, dev_text_pdu.text_type, dev_text_pdu.locale_id)? - { - Ok(vec![Box::new(QueryDeviceTextRsp { - msg_id: dev_text_pdu.msg_id, - udev_iface: dev_text_pdu.udev_iface, - hresult: device_text.hresult, - device_description: device_text.description.into(), - })]) - } else { - Ok(Vec::new()) - } + .query_device_text(channel_id, dev_text_pdu.text_type, dev_text_pdu.locale_id)?; + Ok(vec![Box::new(QueryDeviceTextRsp { + msg_id: dev_text_pdu.msg_id, + udev_iface: dev_text_pdu.udev_iface, + hresult: device_text.hresult, + device_description: device_text.description.into(), + })]) } IoCtl(io_ctl_pdu) => { let msg_id = io_ctl_pdu.msg_id; diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs index 8e4ac16183..40294643bd 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs @@ -18,17 +18,7 @@ use ironrdp_rdpeusb::pdu::{ UrbdrcClientControlPdu, UrbdrcClientDevicePdu, UrbdrcServerControlPdu, UrbdrcServerDevicePdu, }; -use super::simple_device_info; - -const STREAM_ID_PROXY: u32 = 1; - -fn proxy_iface_id(iface: InterfaceId) -> u32 { - u32::from(iface) | (STREAM_ID_PROXY << 30) -} - -fn encode_pdu(pdu: &T) -> Vec { - encode_vec(pdu).expect("encode should succeed") -} +use super::{encode_pdu, proxy_iface_id, simple_device_info}; fn decode_control_msg(message: &DvcMessage) -> UrbdrcClientControlPdu { let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); @@ -81,30 +71,28 @@ impl DeviceManagerBackend for TestDeviceManager { } } -struct TestDeviceBackend { +struct NoopDeviceClientBackend { device_info: DeviceInfo, } -impl TestDeviceBackend { +impl NoopDeviceClientBackend { fn new(device_info: DeviceInfo) -> Self { Self { device_info } } } -impl UrbdrcDeviceBackend for TestDeviceBackend { +impl UrbdrcDeviceBackend for NoopDeviceClientBackend { fn device_info(&mut self, _channel_id: u32) -> PduResult { Ok(self.device_info.clone()) } fn cancel_request(&mut self, _request_id: RequestId, _channel_id: u32) {} - fn query_device_text( - &mut self, - _channel_id: u32, - _text_type: u32, - _locale_id: u32, - ) -> PduResult> { - Ok(None) + fn query_device_text(&mut self, _channel_id: u32, _text_type: u32, _locale_id: u32) -> PduResult { + Ok(DeviceText { + hresult: 0, + description: String::new(), + }) } fn io_control( @@ -169,10 +157,10 @@ fn channel_setup_sequence() { .expect("device manager state lock should not be poisoned"); state .pending_devices - .push_back(Box::new(TestDeviceBackend::new(simple_device_info()))); + .push_back(Box::new(NoopDeviceClientBackend::new(simple_device_info()))); state .pending_devices - .push_back(Box::new(TestDeviceBackend::new(simple_device_info()))); + .push_back(Box::new(NoopDeviceClientBackend::new(simple_device_info()))); } let callback_manager_state = Arc::clone(&manager_state); @@ -295,7 +283,7 @@ fn channel_setup_sequence() { #[test] fn new_device_sequence() { let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); - let backend = Box::new(TestDeviceBackend::new(simple_device_info())); + let backend = Box::new(NoopDeviceClientBackend::new(simple_device_info())); let mut client = UrbdrcDeviceClient::new(udev_iface, backend).expect("device client should be created"); assert!(!client.ready_for_io()); diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/io/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/mod.rs new file mode 100644 index 0000000000..7453d84d6d --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/mod.rs @@ -0,0 +1,355 @@ +use std::sync::mpsc::{self, Receiver, Sender}; + +use ironrdp_core::encode_vec; +use ironrdp_dvc::{DvcMessage, DvcProcessor as _}; +use ironrdp_pdu::PduResult; +use ironrdp_rdpeusb::client::{UrbdrcDeviceBackend, UrbdrcDeviceClient}; +use ironrdp_rdpeusb::io::{ + DeviceAnnounce, DeviceText, InternalIoControlPacket, IoControlCompletionResult, IoControlPacket, RequestId, + TransferInCompletionResult, TransferInPacket, TransferOutCompletionResult, TransferOutPacket, +}; +use ironrdp_rdpeusb::pdu::header::InterfaceId; +use ironrdp_rdpeusb::server::{UrbdrcDeviceServer, UrbdrcDeviceServerBackend}; + +use super::simple_device_info; + +const CHANNEL_ID: u32 = 11; +const DEVICE_TEXT_DESCRIPTION: &str = "Test USB device"; +const DEVICE_TEXT_HRESULT: u32 = 0; + +#[derive(Debug)] +enum ClientEvent { + QueryDeviceText { + channel_id: u32, + text_type: u32, + locale_id: u32, + }, + IoControl { + channel_id: u32, + request_id: RequestId, + request: IoControlPacket, + }, + InternalIoControl { + channel_id: u32, + request_id: RequestId, + request: InternalIoControlPacket, + }, + TransferIn { + channel_id: u32, + request_id: RequestId, + request: TransferInPacket, + }, + TransferOut { + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + }, + TransferOutNoAck { + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + }, + Cancel { + channel_id: u32, + request_id: RequestId, + }, +} + +#[derive(Debug)] +enum ServerEvent { + DeviceText(DeviceText), + IoControlCompleted { + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + }, + InternalIoControlCompleted { + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + }, + TransferInCompleted { + channel_id: u32, + request_id: RequestId, + completion: TransferInCompletionResult, + }, + TransferOutCompleted { + channel_id: u32, + request_id: RequestId, + completion: TransferOutCompletionResult, + }, +} + +struct ChannelClientBackend { + events: Sender, +} + +impl ChannelClientBackend { + fn send(&self, event: ClientEvent) { + self.events + .send(event) + .expect("client event receiver should remain connected"); + } +} + +impl UrbdrcDeviceBackend for ChannelClientBackend { + fn device_info(&mut self, _channel_id: u32) -> PduResult { + Ok(simple_device_info()) + } + + fn cancel_request(&mut self, request_id: RequestId, channel_id: u32) { + self.send(ClientEvent::Cancel { channel_id, request_id }); + } + + fn query_device_text(&mut self, channel_id: u32, text_type: u32, locale_id: u32) -> PduResult { + self.send(ClientEvent::QueryDeviceText { + channel_id, + text_type, + locale_id, + }); + Ok(DeviceText { + hresult: DEVICE_TEXT_HRESULT, + description: DEVICE_TEXT_DESCRIPTION.to_owned(), + }) + } + + fn io_control( + &mut self, + channel_id: u32, + request_id: RequestId, + request: IoControlPacket, + ) -> PduResult> { + self.send(ClientEvent::IoControl { + channel_id, + request_id, + request, + }); + Ok(None) + } + + fn internal_io_control( + &mut self, + channel_id: u32, + request_id: RequestId, + request: InternalIoControlPacket, + ) -> PduResult> { + self.send(ClientEvent::InternalIoControl { + channel_id, + request_id, + request, + }); + Ok(None) + } + + fn transfer_in( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferInPacket, + ) -> PduResult> { + self.send(ClientEvent::TransferIn { + channel_id, + request_id, + request, + }); + Ok(None) + } + + fn transfer_out( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + ) -> PduResult> { + self.send(ClientEvent::TransferOut { + channel_id, + request_id, + request, + }); + Ok(None) + } + + fn transfer_out_no_ack( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + ) -> PduResult<()> { + self.send(ClientEvent::TransferOutNoAck { + channel_id, + request_id, + request, + }); + Ok(()) + } + + fn retract(&mut self, _channel_id: u32) -> PduResult<()> { + Ok(()) + } +} + +struct ChannelDeviceServerBackend { + events: Sender, +} + +impl ChannelDeviceServerBackend { + fn send(&self, event: ServerEvent) { + self.events + .send(event) + .expect("server event receiver should remain connected"); + } +} + +impl UrbdrcDeviceServerBackend for ChannelDeviceServerBackend { + fn add_device(&mut self, _device: DeviceAnnounce) -> PduResult<()> { + Ok(()) + } + + fn device_text(&mut self, device_text: DeviceText) { + self.send(ServerEvent::DeviceText(device_text)); + } + + fn io_control_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + ) -> PduResult<()> { + self.send(ServerEvent::IoControlCompleted { + channel_id, + request_id, + completion, + }); + Ok(()) + } + + fn internal_io_control_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + ) -> PduResult<()> { + self.send(ServerEvent::InternalIoControlCompleted { + channel_id, + request_id, + completion, + }); + Ok(()) + } + + fn transfer_in_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: TransferInCompletionResult, + ) -> PduResult<()> { + self.send(ServerEvent::TransferInCompleted { + channel_id, + request_id, + completion, + }); + Ok(()) + } + + fn transfer_out_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: TransferOutCompletionResult, + ) -> PduResult<()> { + self.send(ServerEvent::TransferOutCompleted { + channel_id, + request_id, + completion, + }); + Ok(()) + } +} + +struct ConnectedDevice { + client: UrbdrcDeviceClient, + server: UrbdrcDeviceServer, + client_events: Receiver, + server_events: Receiver, +} + +impl ConnectedDevice { + fn new() -> Self { + let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); + let completion_iface = InterfaceId::try_from(5).expect("valid completion interface id"); + let (client_events_tx, client_events) = mpsc::channel(); + let (server_events_tx, server_events) = mpsc::channel(); + + let client_backend = Box::new(ChannelClientBackend { + events: client_events_tx, + }); + let server_backend = Box::new(ChannelDeviceServerBackend { + events: server_events_tx, + }); + let mut client = UrbdrcDeviceClient::new(udev_iface, client_backend).expect("device client should be created"); + let mut server = + UrbdrcDeviceServer::new(server_backend, completion_iface).expect("device server should be created"); + + let mut to_client = server.start(CHANNEL_ID).expect("server start should succeed"); + let mut settled = false; + for _ in 0..16 { + let mut to_server = Vec::new(); + for message in to_client { + to_server.extend(process_message(&mut client, message)); + } + if to_server.is_empty() { + settled = true; + break; + } + + to_client = Vec::new(); + for message in to_server { + to_client.extend(process_message(&mut server, message)); + } + if to_client.is_empty() { + settled = true; + break; + } + } + assert!(settled, "device DVC setup should settle"); + assert!(client.ready_for_io()); + + Self { + client, + server, + client_events, + server_events, + } + } + + fn send_to_client(&mut self, message: DvcMessage) -> Vec { + process_message(&mut self.client, message) + } + + fn send_to_server(&mut self, message: DvcMessage) -> Vec { + process_message(&mut self.server, message) + } + + fn next_client_event(&self) -> ClientEvent { + self.client_events.try_recv().expect("client backend should be called") + } + + fn next_server_event(&self) -> ServerEvent { + self.server_events.try_recv().expect("server backend should be called") + } +} + +fn process_message(processor: &mut dyn ironrdp_dvc::DvcProcessor, message: DvcMessage) -> Vec { + let payload = encode_vec(message.as_ref()).expect("DVC message should encode"); + processor + .process(CHANNEL_ID, &payload) + .expect("DVC message should process") +} + +fn only_message(mut messages: Vec) -> DvcMessage { + assert_eq!(messages.len(), 1); + messages.pop().expect("one message should be present") +} + +mod requests; +mod transfers; diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/io/requests.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/requests.rs new file mode 100644 index 0000000000..d3cbdd8819 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/requests.rs @@ -0,0 +1,217 @@ +use ironrdp_rdpeusb::io::{InternalIoControlPacket, IoControlCompletionResult, IoControlPacket, IoctlInternalUsb}; +use rstest::rstest; + +use super::{ + CHANNEL_ID, ClientEvent, ConnectedDevice, DEVICE_TEXT_DESCRIPTION, DEVICE_TEXT_HRESULT, ServerEvent, only_message, +}; + +// Refs: [Query Device Text][2.2.6.5] and [Query Device Text Response][2.2.6.6]. +// [2.2.6.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d03a7696-2d56-4f20-b7a9-a5e72a045956 +// [2.2.6.6]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/acffdcfa-c792-40a4-a8ee-c545ea5b0a38 +#[test] +fn query_device_text_round_trip() { + let mut device = ConnectedDevice::new(); + + let request = device + .server + .query_device_text(1, 0x0409) + .expect("query device text should succeed"); + let response = only_message(device.send_to_client(request)); + + let ClientEvent::QueryDeviceText { + channel_id, + text_type, + locale_id, + } = device.next_client_event() + else { + panic!("expected query device text event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(text_type, 1); + assert_eq!(locale_id, 0x0409); + + assert!(device.send_to_server(response).is_empty()); + let ServerEvent::DeviceText(device_text) = device.next_server_event() else { + panic!("expected device text event"); + }; + assert_eq!(device_text.hresult, DEVICE_TEXT_HRESULT); + assert_eq!(device_text.description, DEVICE_TEXT_DESCRIPTION); +} + +// Ref: [IO Control Completion][2.2.7.1]. +// [2.2.7.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 +#[rstest] +#[case::reset_port( + IoControlPacket { + ioctl_code: IoctlInternalUsb::ResetPort, + input_buffer: Vec::new(), + output_buffer_size: 0, + }, + IoControlCompletionResult { + hresult: 0, + information: 0, + output_buffer: Vec::new(), + }, +)] +#[case::get_port_status( + IoControlPacket { + ioctl_code: IoctlInternalUsb::GetPortStatus, + input_buffer: Vec::new(), + output_buffer_size: 4, + }, + IoControlCompletionResult { + hresult: 0, + information: 4, + output_buffer: vec![1, 0, 0, 0], + }, +)] +#[case::get_hub_name( + IoControlPacket { + ioctl_code: IoctlInternalUsb::GetHubName, + input_buffer: Vec::new(), + output_buffer_size: 8, + }, + IoControlCompletionResult { + hresult: 0, + information: 4, + output_buffer: vec![b'H', 0, b'1', 0], + }, +)] +fn io_control_pending_completion_round_trip( + #[case] packet: IoControlPacket, + #[case] completion: IoControlCompletionResult, +) { + let mut device = ConnectedDevice::new(); + let expected_ioctl_code = packet.ioctl_code; + let expected_input_buffer = packet.input_buffer.clone(); + let expected_output_buffer_size = packet.output_buffer_size; + let expected_hresult = completion.hresult; + let expected_information = completion.information; + let expected_completion_output = completion.output_buffer.clone(); + + let request = device.server.io_control(packet).expect("IO control should succeed"); + assert!(request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::IoControl { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected IO control event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(request.ioctl_code, expected_ioctl_code); + assert_eq!(request.input_buffer, expected_input_buffer); + assert_eq!(request.output_buffer_size, expected_output_buffer_size); + + let response = device + .client + .io_ctl_completion(request_id, completion) + .expect("IO control completion should succeed"); + assert!(device.send_to_server(response).is_empty()); + + let ServerEvent::IoControlCompleted { + channel_id, + request_id: backend_request_id, + completion, + } = device.next_server_event() + else { + panic!("expected IO control completion event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(completion.hresult, expected_hresult); + assert_eq!(completion.information, expected_information); + assert_eq!(completion.output_buffer, expected_completion_output); +} + +// Ref: [Internal IO Control Message][2.2.6.4]. +// [2.2.6.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c3f3e320-336d-4d1b-84c9-51e0ed330ffe +#[test] +fn internal_io_control_pending_completion_round_trip() { + let mut device = ConnectedDevice::new(); + let request = InternalIoControlPacket::QueryBusTime; + + let request = device + .server + .internal_io_control(request) + .expect("internal IO control should succeed"); + assert!(request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::InternalIoControl { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected internal IO control event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert!(matches!(request, InternalIoControlPacket::QueryBusTime)); + + let completion = IoControlCompletionResult { + hresult: 0, + information: 4, + output_buffer: vec![42, 0, 0, 0], + }; + let response = device + .client + .internal_io_ctl_completion(request_id, completion) + .expect("internal IO control completion should succeed"); + assert!(device.send_to_server(response).is_empty()); + + let ServerEvent::InternalIoControlCompleted { + channel_id, + request_id: backend_request_id, + completion, + } = device.next_server_event() + else { + panic!("expected internal IO control completion event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(completion.hresult, 0); + assert_eq!(completion.information, 4); + assert_eq!(completion.output_buffer, [42, 0, 0, 0]); +} + +// Ref: [Processing a Cancel Request Message][3.3.5.3.1]. +// [3.3.5.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d5315234-d9ba-42dc-bc1b-b421c57a21ae +#[test] +fn cancel_pending_request() { + let mut device = ConnectedDevice::new(); + let request = device + .server + .io_control(IoControlPacket { + ioctl_code: IoctlInternalUsb::GetPortStatus, + input_buffer: Vec::new(), + output_buffer_size: 4, + }) + .expect("IO control should succeed"); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + assert!(matches!(device.next_client_event(), ClientEvent::IoControl { .. })); + + let cancel = device + .server + .cancel_request(request_id) + .expect("cancel request should succeed"); + assert!(device.send_to_client(cancel).is_empty()); + + let ClientEvent::Cancel { + channel_id, + request_id: backend_request_id, + } = device.next_client_event() + else { + panic!("expected cancel event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/io/transfers.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/transfers.rs new file mode 100644 index 0000000000..84e4f95c86 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/transfers.rs @@ -0,0 +1,289 @@ +use ironrdp_rdpeusb::io::{ + TransferInCompletionResult, TransferInPacket, TransferOutCompletionResult, TransferOutPacket, TsUrbInKind, + TsUrbInPacket, TsUrbOutKind, TsUrbOutPacket, UrbFunction, +}; +use ironrdp_rdpeusb::pdu::completion::ts_urb_result::{TsUrbResult, TsUrbResultHeader, TsUrbResultPayload}; +use ironrdp_rdpeusb::pdu::usb_dev::ts_urb::utils::SetupPacket; +use ironrdp_rdpeusb::pdu::usb_dev::ts_urb::{ + TsUrbBulkOrInterruptTransfer, TsUrbControlGetConfigRequest, TsUrbControlGetInterfaceRequest, + TsUrbControlGetStatusRequest, TsUrbControlTransfer, TsUrbControlVendorClassRequest, TsUrbIsochTransfer, +}; +use ironrdp_rdpeusb::pdu::utils::UsbdIsoPacketDesc; +use rstest::rstest; + +use super::{CHANNEL_ID, ClientEvent, ConnectedDevice, ServerEvent}; + +fn successful_urb_result() -> TsUrbResult { + TsUrbResult { + header: TsUrbResultHeader { usbd_status: 0 }, + payload: TsUrbResultPayload::Raw(Vec::new()), + } +} + +// Refs: [URB Completion][2.2.7.2] and [URB Completion No Data][2.2.7.3]. +// [2.2.7.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5bfa9c84-a74b-4942-9d09-e770b21081eb +// [2.2.7.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/994fac8f-d258-47a6-aa35-48783abe49ec +#[rstest] +#[case::get_configuration( + TransferInPacket { + ts_urb: TsUrbInPacket { + kind: TsUrbInKind::CtlGetConfig(TsUrbControlGetConfigRequest), + func: UrbFunction::URB_FUNCTION_GET_CONFIGURATION, + }, + output_buffer_size: 1, + }, + vec![0x01], +)] +#[case::get_configuration_without_data( + TransferInPacket { + ts_urb: TsUrbInPacket { + kind: TsUrbInKind::CtlGetConfig(TsUrbControlGetConfigRequest), + func: UrbFunction::URB_FUNCTION_GET_CONFIGURATION, + }, + output_buffer_size: 1, + }, + Vec::new(), +)] +#[case::get_interface( + TransferInPacket { + ts_urb: TsUrbInPacket { + kind: TsUrbInKind::CtlGetIface(TsUrbControlGetInterfaceRequest { interface: 2 }), + func: UrbFunction::URB_FUNCTION_GET_INTERFACE, + }, + output_buffer_size: 1, + }, + vec![0x02], +)] +#[case::get_status( + TransferInPacket { + ts_urb: TsUrbInPacket { + kind: TsUrbInKind::CtlGetStatus(TsUrbControlGetStatusRequest { index: 0x81 }), + func: UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT, + }, + output_buffer_size: 2, + }, + vec![0x01, 0x00], +)] +fn transfer_in_completion_round_trip(#[case] packet: TransferInPacket, #[case] output_buffer: Vec) { + let mut device = ConnectedDevice::new(); + let expected_kind = packet.ts_urb.kind.clone(); + let expected_func = packet.ts_urb.func; + let expected_output_buffer_size = packet.output_buffer_size; + let request = device.server.transfer_in(packet).expect("transfer in should succeed"); + assert!(request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::TransferIn { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected transfer in event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(request.ts_urb.kind, expected_kind); + assert_eq!(request.ts_urb.func, expected_func); + assert_eq!(request.output_buffer_size, expected_output_buffer_size); + + let response = device + .client + .transfer_in_completion( + request_id, + TransferInCompletionResult { + ts_urb_result: successful_urb_result(), + hresult: 0, + output_buffer: output_buffer.clone(), + }, + ) + .expect("transfer in completion should succeed"); + assert!(device.send_to_server(response).is_empty()); + + let ServerEvent::TransferInCompleted { + channel_id, + request_id: backend_request_id, + completion, + } = device.next_server_event() + else { + panic!("expected transfer in completion event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(completion.ts_urb_result, successful_urb_result()); + assert_eq!(completion.hresult, 0); + assert_eq!(completion.output_buffer, output_buffer); +} + +// Ref: [Transfer Out Request][2.2.6.8]. +// [2.2.6.8]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6d6c85b2-47bb-4674-975a-dc7d8ed684cd +#[rstest] +#[case::bulk_or_interrupt( + TransferOutPacket { + ts_urb: TsUrbOutPacket { + kind: TsUrbOutKind::BulkInterruptTransfer(TsUrbBulkOrInterruptTransfer { + pipe_handle: 7, + transfer_flags: 0, + }), + no_ack: false, + func: UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER, + }, + output_buffer: vec![1, 2, 3], + }, + TransferOutCompletionResult { + ts_urb_result: successful_urb_result(), + hresult: 0, + output_buffer_size: 3, + }, +)] +#[case::control( + TransferOutPacket { + ts_urb: TsUrbOutPacket { + kind: TsUrbOutKind::CtlTransfer(TsUrbControlTransfer { + pipe: 0, + transfer_flags: 0, + setup_packet: SetupPacket { + request_type: 0, + request: 9, + value: 1, + index: 0, + length: 0, + }, + }), + no_ack: false, + func: UrbFunction::URB_FUNCTION_CONTROL_TRANSFER, + }, + output_buffer: Vec::new(), + }, + TransferOutCompletionResult { + ts_urb_result: successful_urb_result(), + hresult: 0, + output_buffer_size: 0, + }, +)] +#[case::vendor( + TransferOutPacket { + ts_urb: TsUrbOutPacket { + kind: TsUrbOutKind::VendorClassReq(TsUrbControlVendorClassRequest { + transfer_flags: 0, + request: 1, + value: 2, + index: 3, + }), + no_ack: false, + func: UrbFunction::URB_FUNCTION_VENDOR_DEVICE, + }, + output_buffer: vec![4, 5], + }, + TransferOutCompletionResult { + ts_urb_result: successful_urb_result(), + hresult: 0, + output_buffer_size: 2, + }, +)] +fn transfer_out_completion_round_trip( + #[case] packet: TransferOutPacket, + #[case] completion: TransferOutCompletionResult, +) { + let mut device = ConnectedDevice::new(); + let expected_kind = packet.ts_urb.kind.clone(); + let expected_func = packet.ts_urb.func; + let expected_output_buffer = packet.output_buffer.clone(); + let expected_ts_urb_result = completion.ts_urb_result.clone(); + let expected_hresult = completion.hresult; + let expected_output_buffer_size = completion.output_buffer_size; + let request = device.server.transfer_out(packet).expect("transfer out should succeed"); + assert!(request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::TransferOut { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected transfer out event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(request.ts_urb.kind, expected_kind); + assert!(!request.ts_urb.no_ack); + assert_eq!(request.ts_urb.func, expected_func); + assert_eq!(request.output_buffer, expected_output_buffer); + + let response = device + .client + .transfer_out_completion(request_id, completion) + .expect("transfer out completion should succeed"); + assert!(device.send_to_server(response).is_empty()); + + let ServerEvent::TransferOutCompleted { + channel_id, + request_id: backend_request_id, + completion, + } = device.next_server_event() + else { + panic!("expected transfer out completion event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(completion.ts_urb_result, expected_ts_urb_result); + assert_eq!(completion.hresult, expected_hresult); + assert_eq!(completion.output_buffer_size, expected_output_buffer_size); +} + +#[test] +fn transfer_out_no_ack() { + let mut device = ConnectedDevice::new(); + let request = device + .server + .transfer_out(TransferOutPacket { + ts_urb: TsUrbOutPacket { + kind: TsUrbOutKind::IsochTransfer(TsUrbIsochTransfer { + pipe_handle: 7, + transfer_flags: 0, + start_frame: 100, + error_count: 0, + iso_packet: vec![UsbdIsoPacketDesc { + offset: 0, + length: 3, + status: 0, + }], + }), + no_ack: true, + func: UrbFunction::URB_FUNCTION_ISOCH_TRANSFER, + }, + output_buffer: vec![1, 2, 3], + }) + .expect("no-ack transfer out should succeed"); + assert!(!request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::TransferOutNoAck { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected no-ack transfer out event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + let TsUrbOutKind::IsochTransfer(urb) = request.ts_urb.kind else { + panic!("expected isochronous transfer"); + }; + assert_eq!(urb.pipe_handle, 7); + assert_eq!(urb.transfer_flags, 0); + assert_eq!(urb.start_frame, 100); + assert_eq!(urb.error_count, 0); + assert_eq!(urb.iso_packet.len(), 1); + assert_eq!(urb.iso_packet[0].offset, 0); + assert_eq!(urb.iso_packet[0].length, 3); + assert_eq!(urb.iso_packet[0].status, 0); + assert!(request.ts_urb.no_ack); + assert_eq!(request.ts_urb.func, UrbFunction::URB_FUNCTION_ISOCH_TRANSFER); + assert_eq!(request.output_buffer, [1, 2, 3]); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs index 9741b9b5c1..d92fe641b8 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs @@ -1,6 +1,10 @@ -use ironrdp_rdpeusb::io::device::{ - DeviceInfo, UsbBcdVersion, UsbClassCodes, UsbConfigInfo, UsbConnectionSpeed, UsbDeviceDescriptorInfo, - UsbDeviceLocation, UsbInterfaceInfo, +use ironrdp_core::encode_vec; +use ironrdp_rdpeusb::{ + io::device::{ + DeviceInfo, UsbBcdVersion, UsbClassCodes, UsbConfigInfo, UsbConnectionSpeed, UsbDeviceDescriptorInfo, + UsbDeviceLocation, UsbInterfaceInfo, + }, + pdu::header::InterfaceId, }; fn simple_device_info() -> DeviceInfo { @@ -31,5 +35,17 @@ fn simple_device_info() -> DeviceInfo { } } +const STREAM_ID_PROXY: u32 = 1; + +fn proxy_iface_id(iface: InterfaceId) -> u32 { + u32::from(iface) | (STREAM_ID_PROXY << 30) +} + +fn encode_pdu(pdu: &T) -> Vec { + encode_vec(pdu).expect("encode should succeed") +} + mod client; mod device; +mod io; +mod server; diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/server.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/server.rs new file mode 100644 index 0000000000..234d65a410 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/server.rs @@ -0,0 +1,226 @@ +use std::sync::mpsc::{self, Sender, TryRecvError}; + +use ironrdp_core::{decode, encode_vec}; +use ironrdp_dvc::{DvcMessage, DvcProcessor as _}; +use ironrdp_pdu::PduResult; +use ironrdp_rdpeusb::CHANNEL_NAME; +use ironrdp_rdpeusb::io::device::add_device_from_info; +use ironrdp_rdpeusb::io::{ + DeviceAnnounce, DeviceText, IoControlCompletionResult, RequestId, TransferInCompletionResult, + TransferOutCompletionResult, +}; +use ironrdp_rdpeusb::pdu::caps::{Capability, RimExchangeCapabilityResponse}; +use ironrdp_rdpeusb::pdu::header::InterfaceId; +use ironrdp_rdpeusb::pdu::notify::{ChannelCreated, Direction}; +use ironrdp_rdpeusb::pdu::sink::AddVirtualChannel; +use ironrdp_rdpeusb::pdu::{ + UrbdrcClientControlPdu, UrbdrcClientDevicePdu, UrbdrcServerControlPdu, UrbdrcServerDevicePdu, +}; +use ironrdp_rdpeusb::server::{ + UrbdrcControlServer, UrbdrcControlServerBackend, UrbdrcDeviceServer, UrbdrcDeviceServerBackend, +}; + +use super::{encode_pdu, proxy_iface_id, simple_device_info}; + +fn decode_control_msg(message: &DvcMessage) -> UrbdrcServerControlPdu { + let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); + decode(&encoded).expect("decode should succeed") +} + +fn decode_device_msg(message: &DvcMessage) -> UrbdrcServerDevicePdu { + let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); + decode(&encoded).expect("decode should succeed") +} + +struct TestControlBackend { + device_channel_created: Sender<()>, +} + +impl UrbdrcControlServerBackend for TestControlBackend { + fn create_device_chan(&mut self) -> PduResult<()> { + self.device_channel_created + .send(()) + .expect("device channel receiver should remain connected"); + Ok(()) + } +} + +struct TestDeviceBackend { + device_announced: Sender, +} + +impl UrbdrcDeviceServerBackend for TestDeviceBackend { + fn add_device(&mut self, device: DeviceAnnounce) -> PduResult<()> { + self.device_announced + .send(device) + .expect("device announcement receiver should remain connected"); + Ok(()) + } + + fn device_text(&mut self, _device_text: DeviceText) {} + + fn io_control_completed( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _completion: IoControlCompletionResult, + ) -> PduResult<()> { + Ok(()) + } + + fn internal_io_control_completed( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _completion: IoControlCompletionResult, + ) -> PduResult<()> { + Ok(()) + } + + fn transfer_in_completed( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _completion: TransferInCompletionResult, + ) -> PduResult<()> { + Ok(()) + } + + fn transfer_out_completed( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _completion: TransferOutCompletionResult, + ) -> PduResult<()> { + Ok(()) + } +} + +// Ref: [Channel Setup Sequence][1.3.1.1] +// [1.3.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/55bb34fc-7fd0-4aca-8739-5fb6759b66fc +#[test] +fn capability_exchange_sequence() { + let (device_channel_created, channel_created_rx) = mpsc::channel(); + let backend = Box::new(TestControlBackend { device_channel_created }); + let mut server = UrbdrcControlServer::new(backend); + + assert_eq!(server.channel_name(), CHANNEL_NAME); + + let resp = server.start(10).expect("start should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcServerControlPdu::Caps(request) = decode_control_msg(&resp[0]) else { + panic!("expected capability request"); + }; + assert_eq!(request.capability, Capability::RimCapabilityVersion01); + + let resp = server + .process( + 10, + &encode_pdu(&UrbdrcClientControlPdu::Caps(RimExchangeCapabilityResponse { + msg_id: request.msg_id, + capability: Capability::RimCapabilityVersion01, + result: 0, + })), + ) + .expect("capability response should succeed"); + assert_eq!(resp.len(), 2); + + let UrbdrcServerControlPdu::IfaceRelease(release) = decode_control_msg(&resp[0]) else { + panic!("expected capabilities interface release"); + }; + assert_eq!(release.iface_id, u32::from(InterfaceId::CAPABILITIES)); + + let UrbdrcServerControlPdu::ChanCreated(channel_created_request) = decode_control_msg(&resp[1]) else { + panic!("expected channel-created request"); + }; + assert_eq!(channel_created_request.direction, Direction::ToClient); + + let resp = server + .process( + 10, + &encode_pdu(&UrbdrcClientControlPdu::ChanCreated(ChannelCreated { + msg_id: channel_created_request.msg_id, + direction: Direction::ToServer, + })), + ) + .expect("channel-created response should succeed"); + assert_eq!(resp.len(), 1); + + let UrbdrcServerControlPdu::IfaceRelease(release) = decode_control_msg(&resp[0]) else { + panic!("expected notification interface release"); + }; + assert_eq!(release.iface_id, proxy_iface_id(InterfaceId::NOTIFY_CLIENT)); + + let resp = server + .process( + 10, + &encode_pdu(&UrbdrcClientControlPdu::AddChan(AddVirtualChannel { msg_id: 0 })), + ) + .expect("add virtual channel should succeed"); + assert!(resp.is_empty()); + channel_created_rx.try_recv().expect("backend should be notified"); + assert!( + matches!(channel_created_rx.try_recv(), Err(TryRecvError::Empty)), + "backend should be notified exactly once" + ); +} + +// Ref: [New Device Sequence][1.3.1.2] +// [1.3.1.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/7e3da218-9cdc-4ebd-bb76-e70202c7f264 +#[test] +fn new_device_sequence() { + let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); + let completion_iface = InterfaceId::try_from(5).expect("valid completion interface id"); + let (device_announced, announcement_rx) = mpsc::channel(); + let backend = Box::new(TestDeviceBackend { device_announced }); + let mut server = UrbdrcDeviceServer::new(backend, completion_iface).expect("device server should be created"); + + assert_eq!(server.channel_name(), CHANNEL_NAME); + + let resp = server.start(11).expect("start should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcServerDevicePdu::ChanCreated(channel_created_request) = decode_device_msg(&resp[0]) else { + panic!("expected channel-created request"); + }; + assert_eq!(channel_created_request.direction, Direction::ToClient); + + let resp = server + .process( + 11, + &encode_pdu(&UrbdrcClientDevicePdu::ChanCreated(ChannelCreated { + msg_id: channel_created_request.msg_id, + direction: Direction::ToServer, + })), + ) + .expect("channel-created response should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcServerDevicePdu::IfaceRelease(release) = decode_device_msg(&resp[0]) else { + panic!("expected notification interface release"); + }; + assert_eq!(release.iface_id, proxy_iface_id(InterfaceId::NOTIFY_CLIENT)); + + let add_device = add_device_from_info(udev_iface, &simple_device_info()).expect("ADD_DEVICE should be generated"); + let resp = server + .process(11, &encode_pdu(&UrbdrcClientDevicePdu::AddDev(add_device))) + .expect("add device should succeed"); + assert_eq!(resp.len(), 2); + + let UrbdrcServerDevicePdu::IfaceRelease(release) = decode_device_msg(&resp[0]) else { + panic!("expected device sink interface release"); + }; + assert_eq!(release.iface_id, proxy_iface_id(InterfaceId::DEVICE_SINK)); + + let UrbdrcServerDevicePdu::RegReqCb(register) = decode_device_msg(&resp[1]) else { + panic!("expected request callback registration"); + }; + assert_eq!(register.udev_iface, udev_iface); + assert_eq!(register.request_completion, Some(completion_iface)); + + announcement_rx + .try_recv() + .expect("backend should receive device announcement"); + assert!( + matches!(announcement_rx.try_recv(), Err(TryRecvError::Empty)), + "device should be announced exactly once" + ); +} From 079b48422b0b78d37beb76994950a2a07c442a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 13 Jul 2026 10:51:48 -0400 Subject: [PATCH 325/325] fix(dvc-pipe-proxy): handle pre-connected Windows clients (#1447) --- Cargo.lock | 2 + .../src/platform/windows.rs | 32 ++++- crates/ironrdp-dvc-pipe-proxy/src/proxy.rs | 24 +++- crates/ironrdp-dvc-pipe-proxy/src/worker.rs | 134 +++++++++++------- crates/ironrdp-testsuite-extra/Cargo.toml | 4 +- .../tests/dvc_pipe_proxy.rs | 44 ++++++ crates/ironrdp-testsuite-extra/tests/main.rs | 1 + 7 files changed, 177 insertions(+), 64 deletions(-) create mode 100644 crates/ironrdp-testsuite-extra/tests/dvc_pipe_proxy.rs diff --git a/Cargo.lock b/Cargo.lock index 0c13422075..ee30a1449d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2965,6 +2965,8 @@ dependencies = [ "ironrdp-async", "ironrdp-client", "ironrdp-core", + "ironrdp-dvc", + "ironrdp-dvc-pipe-proxy", "ironrdp-input", "ironrdp-propertyset", "ironrdp-tls", diff --git a/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs b/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs index 7d40ac3251..7c69bcba9e 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs @@ -1,13 +1,16 @@ use async_trait::async_trait; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe; +use tracing::debug; use crate::error::DvcPipeProxyError; use crate::os_pipe::OsPipe; const PIPE_BUFFER_SIZE: u32 = 64 * 1024; +// ConnectNamedPipe reports this when the client wins the create/accept race. +const ERROR_PIPE_CONNECTED: i32 = 535; -/// Unix-specific implementation of the OS pipe trait. +/// Windows-specific implementation of the OS pipe trait. pub(crate) struct WindowsPipe { pipe_server: named_pipe::NamedPipeServer, } @@ -15,7 +18,8 @@ pub(crate) struct WindowsPipe { #[async_trait] impl OsPipe for WindowsPipe { async fn connect(pipe_name: &str) -> Result { - let pipe_name = format!("\\\\.\\pipe\\{pipe_name}"); + let pipe_path = format!("\\\\.\\pipe\\{pipe_name}"); + debug!(%pipe_name, %pipe_path, "Creating DVC proxy Windows named pipe"); let pipe_server = named_pipe::ServerOptions::new() .first_pipe_instance(true) @@ -25,10 +29,28 @@ impl OsPipe for WindowsPipe { .in_buffer_size(PIPE_BUFFER_SIZE) .out_buffer_size(PIPE_BUFFER_SIZE) .pipe_mode(named_pipe::PipeMode::Byte) - .create(pipe_name) - .map_err(DvcPipeProxyError::Io)?; + .create(&pipe_path) + .map_err(|error| { + debug!(%pipe_name, %pipe_path, %error, "Failed to create DVC proxy Windows named pipe"); + DvcPipeProxyError::Io(error) + })?; - pipe_server.connect().await.map_err(DvcPipeProxyError::Io)?; + debug!(%pipe_name, %pipe_path, "Waiting for DVC proxy Windows named-pipe client"); + match pipe_server.connect().await { + Ok(()) => {} + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_CONNECTED) => { + debug!( + %pipe_name, + %pipe_path, + "DVC proxy Windows named-pipe client connected before accept" + ); + } + Err(error) => { + debug!(%pipe_name, %pipe_path, %error, "Failed to accept DVC proxy Windows named-pipe client"); + return Err(DvcPipeProxyError::Io(error)); + } + } + debug!(%pipe_name, %pipe_path, "Connected DVC proxy Windows named-pipe client"); Ok(Self { pipe_server }) } diff --git a/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs b/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs index 075d5c745d..11c7598a89 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs @@ -4,7 +4,7 @@ use ironrdp_core::impl_as_any; use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; use ironrdp_pdu::{PduResult, pdu_other_err}; use ironrdp_svc::SvcMessage; -use tracing::debug; +use tracing::{debug, error}; use crate::worker::{OnWriteDvcMessage, WorkerCtx, run_worker}; @@ -69,17 +69,27 @@ impl DvcProcessor for DvcNamedPipeProxy { channel_id, }; + #[cfg(not(target_os = "windows"))] + let worker = run_worker::(ctx); + + #[cfg(target_os = "windows")] + let worker = run_worker::(ctx); + + if let Err(worker_error) = worker { + error!( + channel_name = %self.channel_name, + pipe_name = %self.named_pipe_name, + %worker_error, + "Failed to start DVC pipe proxy worker thread" + ); + return Err(pdu_other_err!("start DVC pipe proxy worker: {worker_error}")); + } + self.worker = Some(WorkerControlCtx { to_pipe_tx, abort_event, }); - #[cfg(not(target_os = "windows"))] - run_worker::(ctx); - - #[cfg(target_os = "windows")] - run_worker::(ctx); - Ok(vec![]) } diff --git a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs index e7c3ace6dd..241e2c9813 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs @@ -1,3 +1,4 @@ +use core::time::Duration; use std::sync::{Arc, mpsc}; use ironrdp_dvc::encode_dvc_messages; @@ -11,6 +12,8 @@ use crate::message::RawDataDvcMessage; use crate::os_pipe::OsPipe; const IO_BUFFER_SIZE: usize = 1024 * 64; // 64K +const INITIAL_RECONNECT_DELAY: Duration = Duration::from_millis(100); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(5); pub(crate) type OnWriteDvcMessage = Box) -> PduResult<()> + Send>; @@ -23,38 +26,85 @@ pub(crate) struct WorkerCtx { pub(crate) channel_id: u32, } -pub(crate) fn run_worker(ctx: WorkerCtx) { - let _ = std::thread::spawn(move || { +pub(crate) fn run_worker(ctx: WorkerCtx) -> std::io::Result<()> { + let thread_name = format!("ironrdp-dvc-pipe-{}", ctx.channel_id); + let (startup_tx, startup_rx) = mpsc::sync_channel(1); + + std::thread::Builder::new().name(thread_name).spawn(move || { let channel_name = ctx.channel_name.clone(); let pipe_name = ctx.pipe_name.clone(); + debug!(%channel_name, %pipe_name, "Starting DVC pipe proxy worker thread"); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(DvcPipeProxyError::Io); - - let runtime = match runtime { + let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() { Ok(runtime) => runtime, Err(error) => { error!( %channel_name, %pipe_name, - ?error, - "DVC pipe proxy worker thread initialization failed" + %error, + "Failed to initialize DVC pipe proxy worker thread" ); + let _ = startup_tx.send(Err(error)); return; } }; - if let Err(error) = runtime.block_on(worker::

(ctx)) { + let (async_tx, async_rx) = tokio::sync::mpsc::unbounded_channel(); + let WorkerCtx { + on_write_dvc, + to_pipe_rx: std_rx, + abort_event, + pipe_name, + channel_name, + channel_id, + } = ctx; + + let bridge_thread_name = format!("ironrdp-dvc-pipe-{channel_id}-bridge"); + if let Err(error) = std::thread::Builder::new().name(bridge_thread_name).spawn(move || { + while let Ok(data) = std_rx.recv() { + if async_tx.send(data).is_err() { + break; // Receiver dropped + } + } + }) { error!( %channel_name, %pipe_name, - ?error, - "DVC pipe proxy worker thread has failed" + %error, + "Failed to start DVC pipe proxy bridge thread" ); + let _ = startup_tx.send(Err(error)); + return; + } + + let ctx = BridgedWorkerCtx { + on_write_dvc, + to_pipe_rx: async_rx, + abort_event, + pipe_name, + channel_name, + channel_id, + }; + + if startup_tx.send(Ok(())).is_err() { + return; + } + + debug!( + channel_name = %ctx.channel_name, + pipe_name = %ctx.pipe_name, + "Started DVC pipe proxy worker thread" + ); + if let Err(error) = runtime.block_on(worker::

(ctx)) { + error!(?error, "DVC pipe proxy worker thread has failed"); } - }); + })?; + + startup_rx.recv().unwrap_or_else(|_| { + Err(std::io::Error::other( + "dvc pipe proxy worker stopped before startup completed", + )) + }) } enum NextWorkerState { @@ -134,49 +184,30 @@ async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result(ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { - // Create a bridge between std::sync::mpsc and tokio for async compatibility. - // It is fine to use unbounded channel here because we are using it only to - // forward data from a bounded channel (with size IO_MPSC_CHANNEL_SIZE), - // so we will never have unbounded memory growth. - let (async_tx, async_rx) = tokio::sync::mpsc::unbounded_channel(); - - let WorkerCtx { - on_write_dvc, - to_pipe_rx: std_rx, - abort_event, - pipe_name, - channel_name, - channel_id, - } = ctx; - - // Spawn a thread to bridge std::sync::mpsc to tokio::sync::mpsc. - std::thread::spawn(move || { - while let Ok(data) = std_rx.recv() { - if async_tx.send(data).is_err() { - break; // Receiver dropped - } - } - }); - - let mut bridged_ctx = BridgedWorkerCtx { - on_write_dvc, - to_pipe_rx: async_rx, - abort_event, - pipe_name, - channel_name, - channel_id, - }; +async fn worker(mut bridged_ctx: BridgedWorkerCtx) -> Result<(), DvcPipeProxyError> { + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + loop { - match process_client::

(&mut bridged_ctx).await? { - NextWorkerState::Abort => { + match process_client::

(&mut bridged_ctx).await { + Err(error) => { + error!( + channel_name = %bridged_ctx.channel_name, + pipe_name = %bridged_ctx.pipe_name, + ?error, + retry_delay_ms = reconnect_delay.as_millis(), + "DVC pipe proxy connection failed; retrying" + ); + std::thread::sleep(reconnect_delay); + reconnect_delay = reconnect_delay.saturating_mul(2).min(MAX_RECONNECT_DELAY); + } + Ok(NextWorkerState::Abort) => { debug!( channel_name = %bridged_ctx.channel_name, pipe_name = %bridged_ctx.pipe_name, @@ -184,7 +215,8 @@ async fn worker(ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { ); break; } - NextWorkerState::Reconnect => { + Ok(NextWorkerState::Reconnect) => { + reconnect_delay = INITIAL_RECONNECT_DELAY; debug!( channel_name = %bridged_ctx.channel_name, pipe_name = %bridged_ctx.pipe_name, diff --git a/crates/ironrdp-testsuite-extra/Cargo.toml b/crates/ironrdp-testsuite-extra/Cargo.toml index 89c541562f..9e98415bfe 100644 --- a/crates/ironrdp-testsuite-extra/Cargo.toml +++ b/crates/ironrdp-testsuite-extra/Cargo.toml @@ -29,6 +29,8 @@ ironrdp-async.path = "../ironrdp-async" ironrdp-agent = { path = "../ironrdp-agent", features = ["internal"] } ironrdp-client.path = "../ironrdp-client" ironrdp-core.path = "../ironrdp-core" +ironrdp-dvc.path = "../ironrdp-dvc" +ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" ironrdp-input.path = "../ironrdp-input" ironrdp-propertyset.path = "../ironrdp-propertyset" ironrdp-viewer.path = "../ironrdp-viewer" @@ -37,7 +39,7 @@ ironrdp-tls = { path = "../ironrdp-tls", features = ["rustls"] } semver = "1.0" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tokio = { version = "1", features = ["sync", "time"] } +tokio = { version = "1", features = ["sync", "time", "net", "rt", "macros", "io-util"] } uuid = { version = "1", features = ["v4"] } [lints] diff --git a/crates/ironrdp-testsuite-extra/tests/dvc_pipe_proxy.rs b/crates/ironrdp-testsuite-extra/tests/dvc_pipe_proxy.rs new file mode 100644 index 0000000000..592c176467 --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/dvc_pipe_proxy.rs @@ -0,0 +1,44 @@ +#[cfg(windows)] +use core::time::Duration; +#[cfg(windows)] +use std::sync::mpsc; + +#[cfg(windows)] +use ironrdp_dvc::DvcProcessor as _; +#[cfg(windows)] +use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; +#[cfg(windows)] +use tokio::io::AsyncWriteExt as _; +#[cfg(windows)] +use tokio::net::windows::named_pipe::ClientOptions; + +#[cfg(windows)] +#[tokio::test] +async fn connects_and_forwards_windows_pipe_data() { + let name = format!("ironrdp-dvc-pipe-proxy-test-{}", std::process::id()); + let (callback_tx, callback_rx) = mpsc::channel(); + let mut proxy = DvcNamedPipeProxy::new("test", &name, move |_, messages| { + callback_tx + .send(messages) + .expect("test callback receiver must remain alive"); + Ok(()) + }); + proxy.start(1).expect("start DVC pipe proxy"); + + let pipe_path = format!(r"\\.\pipe\{name}"); + let mut client = (0..200) + .find_map(|_| match ClientOptions::new().open(&pipe_path) { + Ok(client) => Some(client), + Err(_) => { + std::thread::sleep(Duration::from_millis(10)); + None + } + }) + .expect("DVC pipe proxy must create the pipe within two seconds"); + + client.write_all(b"test data").await.expect("write to DVC pipe"); + let messages = callback_rx + .recv_timeout(Duration::from_secs(1)) + .expect("DVC pipe proxy must forward pipe data to its callback"); + assert!(!messages.is_empty(), "DVC pipe data must produce an SVC message"); +} diff --git a/crates/ironrdp-testsuite-extra/tests/main.rs b/crates/ironrdp-testsuite-extra/tests/main.rs index dc401db0f7..5811e67748 100644 --- a/crates/ironrdp-testsuite-extra/tests/main.rs +++ b/crates/ironrdp-testsuite-extra/tests/main.rs @@ -3,4 +3,5 @@ mod agent; mod client_config; +mod dvc_pipe_proxy; mod e2e;