windows_overlapped_io_sys/device.rs
1// Copyright (c) 2026 Mike Grier
2//! Buffer-owning device-control operation adapters, gated behind the `device`
3//! feature.
4//!
5//! These wrappers own the input and output buffers and issue the single native
6//! `DeviceIoControl` internally, so a caller performs an overlapped device
7//! control without touching `OVERLAPPED` or the submission seam. A device is a
8//! `HANDLE`, so the adapters extend the existing handle endpoints rather than
9//! introducing a device-specific type.
10//!
11//! The `ioctl` methods are `unsafe` because they take an arbitrary control code:
12//! a code whose input structure embeds raw pointers to separate buffers (such as
13//! `SCSI_PASS_THROUGH_DIRECT`) reaches storage these adapters do not own, so only
14//! the caller can guarantee that storage outlives the operation. A self-contained
15//! code -- an `FSCTL` query, say -- needs nothing beyond the owned buffers, but
16//! the seam cannot tell the two apart, so the obligation lives in the contract.
17
18use std::ffi::c_void;
19use std::io;
20use std::os::windows::io::AsRawHandle;
21
22use windows_sys::Win32::Foundation::ERROR_IO_PENDING;
23use windows_sys::Win32::System::IO::DeviceIoControl;
24
25use crate::operation::sync_bytes_ptr_from_overlapped;
26use crate::{
27 AssociatedEndpoint, BlockingEndpoint, Completion, IoBuf, IoBufMut, Issued, Operation,
28 OperationId, Started, Submitted,
29};
30
31impl BlockingEndpoint {
32 /// Issue an overlapped `DeviceIoControl` with control code `code`, blocking
33 /// until it completes.
34 ///
35 /// `input` is the input buffer (empty for control codes that take none) and
36 /// `output` is the buffer the device writes into; the return value is how
37 /// many bytes it wrote. Takes plain slices and allocates nothing: this call
38 /// does not return until the operation is over, so an ordinary borrow
39 /// provably covers the whole time the driver is using them.
40 ///
41 /// # Errors
42 ///
43 /// Returns [`io::ErrorKind::InvalidInput`] if either buffer is longer than
44 /// `u32::MAX` bytes, which the control code's byte counts cannot express, or
45 /// any error from issuing or completing the control operation.
46 ///
47 /// # Safety
48 ///
49 /// `code`'s input layout must be *self-contained*: the driver may read or
50 /// write only the bytes inside `input` and the output buffer, never memory
51 /// reached through a pointer embedded in `input`. Some control codes take an
52 /// input structure that carries raw pointers to separate buffers --
53 /// `SCSI_PASS_THROUGH_DIRECT::DataBuffer` is one -- which this adapter neither
54 /// owns nor keeps alive. For such a code the caller must keep every referenced
55 /// buffer valid for the whole call; the adapter cannot, because it does not
56 /// know the code's layout. This is why the generic raw-code seam is `unsafe`
57 /// even though a self-contained code (an `FSCTL` query, say) needs nothing
58 /// more than owned buffers.
59 pub unsafe fn ioctl(
60 &mut self,
61 code: u32,
62 input: &[u8],
63 output: &mut [u8],
64 ) -> io::Result<usize> {
65 let in_len = checked_len(input.len(), "input")?;
66 let out_len = checked_len(output.len(), "output")?;
67
68 let in_ptr = in_ptr(input.as_ptr(), in_len);
69 let out_ptr = out_ptr(output.as_mut_ptr(), out_len);
70
71 let mut operation = Operation::new(());
72 // SAFETY: issues exactly one DeviceIoControl reading `input` and writing
73 // `output`, both valid for the whole blocking call; no other operation is
74 // outstanding.
75 unsafe {
76 self.run(&mut operation, |handle, overlapped| {
77 let ok = DeviceIoControl(
78 handle.as_raw_handle(),
79 code,
80 in_ptr,
81 in_len,
82 out_ptr,
83 out_len,
84 std::ptr::null_mut(),
85 overlapped,
86 );
87 classify(ok)
88 })
89 }
90 }
91}
92
93/// The input buffer pointer, `NULL` for an empty buffer.
94///
95/// A control code that takes no input must be given `NULL` rather than a
96/// dangling-but-nonnull pointer, which is what an empty buffer's `stable_ptr`
97/// legitimately is.
98fn in_ptr(ptr: *const u8, len: u32) -> *const c_void {
99 if len == 0 {
100 std::ptr::null()
101 } else {
102 ptr.cast()
103 }
104}
105
106/// The output buffer pointer, `NULL` for an empty buffer.
107fn out_ptr(ptr: *mut u8, len: u32) -> *mut c_void {
108 if len == 0 {
109 std::ptr::null_mut()
110 } else {
111 ptr.cast()
112 }
113}
114
115/// Map a native `BOOL` into the submission-seam contract: native success or
116/// `ERROR_IO_PENDING` is accepted, any other error is an immediate failure.
117fn classify(ok: i32) -> io::Result<()> {
118 if ok != 0 {
119 return Ok(());
120 }
121 let error = io::Error::last_os_error();
122 if error.raw_os_error() == Some(ERROR_IO_PENDING as i32) {
123 Ok(())
124 } else {
125 Err(error)
126 }
127}
128
129/// Convert a buffer length to the `u32` byte count `DeviceIoControl` takes.
130///
131/// Rejects rather than caps. Capping would submit a prefix of the caller's
132/// input, or tell the device an output buffer is smaller than it is, and report
133/// success for an operation that did something other than what was asked.
134fn checked_len(len: usize, which: &str) -> io::Result<u32> {
135 u32::try_from(len).map_err(|_| {
136 io::Error::new(
137 io::ErrorKind::InvalidInput,
138 format!(
139 "a DeviceIoControl {which} buffer is limited to u32::MAX bytes; {len} does not fit"
140 ),
141 )
142 })
143}
144
145/// The pinned payload for an in-flight device-control operation: the input and
146/// output buffers, both of which must outlive the async call.
147struct DeviceIoPayload<I, O> {
148 /// Kept alive (pinned, via this struct) for as long as the operation is
149 /// outstanding -- the driver reads it for the whole call -- but never
150 /// read back through this field: `ioctl` captures its stable pointer
151 /// before submission (PR #20 review response) and `claim`/`finish_device`
152 /// drop it unread once the operation completes (the common case only
153 /// wants `output` back).
154 #[allow(dead_code)]
155 input: I,
156 output: O,
157}
158
159impl AssociatedEndpoint<'_> {
160 /// Submit an overlapped `DeviceIoControl` with control code `code`.
161 ///
162 /// Returns [`Started::Pending`] with a [`DeviceIoControlIo`] token that
163 /// recovers the output buffer and byte count from the operation's
164 /// completion, or -- only on an endpoint in
165 /// `FILE_SKIP_COMPLETION_PORT_ON_SUCCESS` mode, where a synchronous success
166 /// queues no packet -- [`Started::Completed`] with the output buffer already
167 /// in hand.
168 ///
169 /// `input` is the input buffer (empty for control codes that take none) and
170 /// `output` is the buffer the device writes its result into. Both are owned
171 /// buffers of the caller's choosing, handed over for the operation's life
172 /// and returned when it completes: nothing is copied and nothing is
173 /// allocated here.
174 ///
175 /// # Errors
176 ///
177 /// Returns [`io::ErrorKind::InvalidInput`] if either buffer is longer than
178 /// `u32::MAX` bytes, which the control code's byte counts cannot express, or
179 /// any immediate failure from issuing the control operation.
180 ///
181 /// # Safety
182 ///
183 /// `code`'s input layout must be *self-contained*: the driver may read or
184 /// write only the bytes inside `input` and the output buffer, never memory
185 /// reached through a pointer embedded in `input`. Some control codes take an
186 /// input structure that carries raw pointers to separate buffers --
187 /// `SCSI_PASS_THROUGH_DIRECT::DataBuffer` is one -- which this adapter neither
188 /// owns nor keeps alive. Because the operation outlives this call, such a
189 /// pointee could be freed while the driver is still using it. For such a code
190 /// the caller must keep every referenced buffer alive until the operation
191 /// completes; the adapter cannot, because it does not know the code's layout.
192 /// This is why the generic raw-code seam is `unsafe` even though a
193 /// self-contained code (an `FSCTL` query, say) needs nothing more than owned
194 /// buffers.
195 #[track_caller]
196 pub unsafe fn ioctl<I: IoBuf, O: IoBufMut>(
197 &self,
198 code: u32,
199 input: I,
200 mut output: O,
201 ) -> io::Result<Started<DeviceIoControlIo<I, O>, O>> {
202 // The lengths are captured here rather than measured inside the
203 // submission closure, which runs at the FFI boundary and cannot report
204 // an error.
205 let in_len = checked_len(input.bytes_len(), "input")?;
206 let out_len = checked_len(output.bytes_len(), "output")?;
207 let skip = self.notification_modes().skip_completion_port_on_success;
208 // Captured before submission too, alongside the lengths above (PR #20
209 // review response): `IoBuf`/`IoBufMut` are safe trait methods a
210 // caller's own buffer type implements, and `submit`'s safety contract
211 // forbids the closure unwinding -- a panic here, before the operation
212 // is registered, is merely an ordinary panic, where one from inside
213 // the closure would leave the operation permanently outstanding.
214 let in_ptr = in_ptr(input.stable_ptr(), in_len);
215 let out_ptr = out_ptr(output.stable_mut_ptr(), out_len);
216
217 let operation = Operation::new(DeviceIoPayload { input, output });
218 // SAFETY: issues exactly one DeviceIoControl reading from `in_ptr` and
219 // writing into `out_ptr` (captured above; identical to what the
220 // pinned payload would report, per `IoBuf`/`IoBufMut`'s
221 // address-stability contract); they and the byte-count cell live
222 // until the completion is claimed.
223 let submitted = unsafe {
224 self.submit(operation, |handle, overlapped| {
225 let bytes = sync_bytes_ptr_from_overlapped(overlapped);
226 let ok = DeviceIoControl(
227 handle.as_raw_handle(),
228 code,
229 in_ptr,
230 in_len,
231 out_ptr,
232 out_len,
233 bytes,
234 overlapped,
235 );
236 classify_issued(ok, skip, bytes)
237 })
238 };
239 finish_device(submitted)
240 }
241}
242
243/// Map a native `BOOL` into the IOCP submission contract.
244///
245/// # Why an immediate `TRUE` is usually `Pending`
246///
247/// [`Issued`] does not record whether `DeviceIoControl` finished synchronously.
248/// It records whether a **completion packet will arrive on the port**, and for
249/// an overlapped handle bound to an IOCP those are different facts: the I/O
250/// Manager queues a packet for every request it completes, *including* one that
251/// succeeds immediately without returning `ERROR_IO_PENDING`. See
252/// [`Issued::Pending`] for the full statement of that rule.
253///
254/// The single exception is `skip_on_success`, which is why this needs to know
255/// it: on an endpoint in `FILE_SKIP_COMPLETION_PORT_ON_SUCCESS` mode no packet
256/// is queued for an immediate success, so that -- and only that -- is an
257/// [`Issued::Completed`]. Both directions of getting this wrong are serious.
258/// Answering `Completed` when a packet is coming tells the port to reclaim the
259/// operation's storage inline, and the packet then arrives carrying a dangling
260/// `OVERLAPPED` -- a use-after-free on claim. Answering `Pending` when none is
261/// coming leaves the operation counted as outstanding forever, so
262/// [`crate::CompletionPort::run_down`] spins waiting for a packet that will
263/// never be queued.
264///
265/// # Safety
266///
267/// `sync_bytes` must be the byte-count cell of the operation being submitted,
268/// which is live for the whole call.
269unsafe fn classify_issued(
270 ok: i32,
271 skip_on_success: bool,
272 sync_bytes: *mut u32,
273) -> io::Result<Issued> {
274 if ok != 0 {
275 if skip_on_success {
276 // SAFETY: the call reported immediate success, so the kernel has
277 // already written the count and will not write it again.
278 let bytes_transferred = unsafe { *sync_bytes };
279 return Ok(Issued::Completed { bytes_transferred });
280 }
281 return Ok(Issued::Pending);
282 }
283 let error = io::Error::last_os_error();
284 if error.raw_os_error() == Some(ERROR_IO_PENDING as i32) {
285 Ok(Issued::Pending)
286 } else {
287 Err(error)
288 }
289}
290
291/// Turn a device submission outcome into the adapter's two-state outcome.
292fn finish_device<I: IoBuf, O: IoBufMut>(
293 submitted: Submitted<DeviceIoPayload<I, O>>,
294) -> io::Result<Started<DeviceIoControlIo<I, O>, O>> {
295 match submitted {
296 Submitted::Pending(id) => Ok(Started::Pending(DeviceIoControlIo {
297 id,
298 buffers: std::marker::PhantomData,
299 })),
300 Submitted::Completed {
301 operation,
302 bytes_transferred,
303 } => Ok(Started::Completed {
304 payload: operation.into_payload().output,
305 bytes_transferred: bytes_transferred as usize,
306 }),
307 Submitted::Failed { error, .. } => Err(error),
308 }
309}
310
311/// A pending device-control operation submitted through
312/// [`AssociatedEndpoint::ioctl`].
313///
314/// The token carries the operation's identity and remembers both buffer types it
315/// was submitted with, so [`DeviceIoControlIo::claim`] hands back the caller's
316/// own output buffer -- the same value, not a copy -- once the matching
317/// completion is dequeued. The input type is carried too, because it is part of
318/// the payload type the claim must name.
319#[derive(Debug)]
320pub struct DeviceIoControlIo<I, O> {
321 id: OperationId,
322 /// The buffers live in the pinned operation, not here; this only keeps the
323 /// token's type tied to them so `claim` cannot be handed the wrong payload.
324 buffers: std::marker::PhantomData<fn() -> (I, O)>,
325}
326
327impl<I: IoBuf, O: IoBufMut> DeviceIoControlIo<I, O> {
328 /// The identity of the in-flight operation, for cancellation or matching.
329 #[must_use]
330 pub fn id(&self) -> OperationId {
331 self.id
332 }
333
334 /// Claim this operation's result from `completion`.
335 ///
336 /// On a match returns `Ok((output, result))`: `output` is the buffer the
337 /// caller handed over (valid up to the byte count) and `result` is the byte
338 /// count or the operation's error. Returns `Err(self)` when `completion`
339 /// belongs to a different operation.
340 ///
341 /// The input buffer is dropped here: the driver is done reading it, and
342 /// returning both would make the common case pay for the rare one.
343 pub fn claim(self, completion: &Completion) -> Result<(O, io::Result<usize>), Self> {
344 if completion.id() != Some(self.id) {
345 return Err(self);
346 }
347 // SAFETY: the full identity -- address *and* generation -- matches, which
348 // an address alone would not: a recycled address can belong to a later
349 // operation of a different payload type. The match therefore proves this
350 // completion is the Operation<DeviceIoPayload<I, O>> this token
351 // submitted, and the token's own type parameters name that payload;
352 // claim it exactly once.
353 let operation = unsafe { completion.claim::<DeviceIoPayload<I, O>>() };
354 let output = operation.into_payload().output;
355 let result = match completion.error() {
356 Some(error) => Err(io::Error::from_raw_os_error(
357 error.raw_os_error().unwrap_or_default(),
358 )),
359 None => Ok(completion.bytes_transferred() as usize),
360 };
361 Ok((output, result))
362 }
363}
364
365#[cfg(test)]
366mod tests;