diff --git a/rust/qemu-api/meson.build b/rust/qemu-api/meson.build
index 26b8500e7d8..607c3010d1d 100644
--- a/rust/qemu-api/meson.build
+++ b/rust/qemu-api/meson.build
@@ -22,6 +22,7 @@ _qemu_api_rs = static_library(
       'src/cell.rs',
       'src/chardev.rs',
       'src/c_str.rs',
+      'src/errno.rs',
       'src/irq.rs',
       'src/memory.rs',
       'src/module.rs',
diff --git a/rust/qemu-api/src/assertions.rs b/rust/qemu-api/src/assertions.rs
index fa1a18de6fe..104dec39774 100644
--- a/rust/qemu-api/src/assertions.rs
+++ b/rust/qemu-api/src/assertions.rs
@@ -92,3 +92,31 @@ fn types_must_be_equal<T, U>(_: T)
         };
     };
 }
+
+/// Assert that an expression matches a pattern.  This can also be
+/// useful to compare enums that do not implement `Eq`.
+///
+/// # Examples
+///
+/// ```
+/// # use qemu_api::assert_match;
+/// // JoinHandle does not implement `Eq`, therefore the result
+/// // does not either.
+/// let result: Result<std::thread::JoinHandle<()>, u32> = Err(42);
+/// assert_match!(result, Err(42));
+/// ```
+#[macro_export]
+macro_rules! assert_match {
+    ($a:expr, $b:pat) => {
+        assert!(
+            match $a {
+                $b => true,
+                _ => false,
+            },
+            "{} = {:?} does not match {}",
+            stringify!($a),
+            $a,
+            stringify!($b)
+        );
+    };
+}
diff --git a/rust/qemu-api/src/errno.rs b/rust/qemu-api/src/errno.rs
new file mode 100644
index 00000000000..c697f9bef05
--- /dev/null
+++ b/rust/qemu-api/src/errno.rs
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+//! Module to portably convert `errno` into [`io::Error`], and the
+//! return value of functions returning `-errno` into [`io::Result`].
+
+use std::io;
+#[cfg(windows)]
+use std::io::ErrorKind;
+
+/// An `errno` value that can be converted into an [`io::Error`]
+pub struct Errno(u16);
+
+// On Unix, from_raw_os_error takes an errno value and OS errors
+// are printed using strerror.  On Windows however it takes a
+// GetLastError() value; therefore we need to convert errno values
+// into io::Error by hand.  For simplicity use ErrorKind and use
+// the standard library's simple-minded mapping of ErrorKind to Error
+// (`impl From<ErrorKind> for io::Error`).
+//
+// Since this is just for Windows, do not bother with using the libc
+// crate or generating the constants from C.  Just list here the
+// constants that map to stable error kinds.
+#[cfg(windows)]
+mod libc {
+    pub const EPERM: u16 = 1;
+    pub const ENOENT: u16 = 2;
+    pub const EINTR: u16 = 4;
+    pub const EAGAIN: u16 = 11;
+    pub const ENOMEM: u16 = 12;
+    pub const EACCES: u16 = 13;
+    pub const EEXIST: u16 = 17;
+    pub const EINVAL: u16 = 22;
+    pub const EPIPE: u16 = 32;
+    pub const EADDRINUSE: u16 = 100;
+    pub const EADDRNOTAVAIL: u16 = 101;
+    pub const ECONNABORTED: u16 = 106;
+    pub const ECONNREFUSED: u16 = 107;
+    pub const ECONNRESET: u16 = 108;
+    pub const ENOTCONN: u16 = 126;
+    pub const ENOTSUP: u16 = 129;
+    pub const ETIMEDOUT: u16 = 138;
+    pub const EWOULDBLOCK: u16 = 140;
+}
+
+impl From<Errno> for io::Error {
+    #[cfg(unix)]
+    fn from(value: Errno) -> io::Error {
+        let Errno(errno) = value;
+        io::Error::from_raw_os_error(errno.into())
+    }
+
+    #[cfg(windows)]
+    fn from(value: Errno) -> io::Error {
+        let Errno(errno) = value;
+        let error_kind = match errno {
+            libc::EPERM | libc::EACCES => ErrorKind::PermissionDenied,
+            libc::ENOENT => ErrorKind::NotFound,
+            libc::EINTR => ErrorKind::Interrupted,
+            // Note that on Windows we know these two are distinct.  In general,
+            // it would not be possible to use "|".
+            libc::EAGAIN | libc::EWOULDBLOCK => ErrorKind::WouldBlock,
+            libc::ENOMEM => ErrorKind::OutOfMemory,
+            libc::EEXIST => ErrorKind::AlreadyExists,
+            libc::EINVAL => ErrorKind::InvalidInput,
+            libc::EPIPE => ErrorKind::BrokenPipe,
+            libc::EADDRINUSE => ErrorKind::AddrInUse,
+            libc::EADDRNOTAVAIL => ErrorKind::AddrNotAvailable,
+            libc::ECONNABORTED => ErrorKind::ConnectionAborted,
+            libc::ECONNREFUSED => ErrorKind::ConnectionRefused,
+            libc::ECONNRESET => ErrorKind::ConnectionReset,
+            libc::ENOTCONN => ErrorKind::NotConnected,
+            libc::ENOTSUP => ErrorKind::Unsupported,
+            libc::ETIMEDOUT => ErrorKind::TimedOut,
+            _ => ErrorKind::Other,
+        };
+        error_kind.into()
+    }
+}
+
+/// Convert an integer value into a [`Result`], where positive
+/// values are turned into an `Ok` result and negative values are
+/// interpreted as negated errno and turned into an `Err`.
+pub trait GetErrno: Copy {
+    /// Unsigned variant of self, used as the type for the `Ok` case.
+    type Out;
+
+    /// Return `Ok(self)` if positive, `Err(Errno(-self))` if negative
+    fn into_errno_result(self) -> Result<Self::Out, Errno>;
+}
+
+macro_rules! get_errno {
+    ($t:ty, $out:ty) => {
+        impl GetErrno for $t {
+            type Out = $out;
+            fn into_errno_result(self) -> Result<Self::Out, Errno> {
+                match self {
+                    0.. => Ok(self as $out),
+                    -65535..=-1 => Err(Errno(-self as u16)),
+                    _ => panic!("{self} is not a negative errno"),
+                }
+            }
+        }
+    };
+}
+
+get_errno!(i32, u32);
+get_errno!(i64, u64);
+get_errno!(isize, usize);
+
+/// Convert an integer value into a [`io::Result`], where positive
+/// values are turned into an `Ok` result and negative values are
+/// interpreted as negated errno and turn into an `Err`.
+///
+/// ```
+/// # use qemu_api::errno::into_io_result;
+/// # use std::io::ErrorKind;
+///
+/// let err = into_io_result(-1i32).unwrap_err(); // -EPERM
+/// assert_eq!(err.kind(), ErrorKind::PermissionDenied);
+/// ```
+pub fn into_io_result<T: GetErrno>(value: T) -> io::Result<T::Out> {
+    Ok(value.into_errno_result()?)
+}
+
+#[cfg(test)]
+mod tests {
+    use std::io::ErrorKind;
+
+    use super::*;
+    use crate::assert_match;
+
+    #[test]
+    pub fn test_i32() {
+        assert_match!(into_io_result(1234i32), Ok(1234));
+
+        let err = into_io_result(-1i32).unwrap_err();
+        #[cfg(unix)]
+        assert_match!(err.raw_os_error(), Some(1));
+        assert_match!(err.kind(), ErrorKind::PermissionDenied);
+    }
+
+    #[test]
+    pub fn test_i64() {
+        assert_match!(into_io_result(1234i64), Ok(1234));
+
+        let err = into_io_result(-22i64).unwrap_err();
+        #[cfg(unix)]
+        assert_match!(err.raw_os_error(), Some(22));
+        assert_match!(err.kind(), ErrorKind::InvalidInput);
+    }
+
+    #[test]
+    pub fn test_isize() {
+        assert_match!(into_io_result(1234isize), Ok(1234));
+
+        let err = into_io_result(-4isize).unwrap_err();
+        #[cfg(unix)]
+        assert_match!(err.raw_os_error(), Some(4));
+        assert_match!(err.kind(), ErrorKind::Interrupted);
+    }
+}
diff --git a/rust/qemu-api/src/lib.rs b/rust/qemu-api/src/lib.rs
index ed1a8f9a2b4..05f38b51d30 100644
--- a/rust/qemu-api/src/lib.rs
+++ b/rust/qemu-api/src/lib.rs
@@ -19,6 +19,7 @@
 pub mod callbacks;
 pub mod cell;
 pub mod chardev;
+pub mod errno;
 pub mod irq;
 pub mod memory;
 pub mod module;
diff --git a/rust/qemu-api/src/prelude.rs b/rust/qemu-api/src/prelude.rs
index fbf0ee23e0b..634acf37a85 100644
--- a/rust/qemu-api/src/prelude.rs
+++ b/rust/qemu-api/src/prelude.rs
@@ -9,6 +9,8 @@
 pub use crate::cell::BqlCell;
 pub use crate::cell::BqlRefCell;
 
+pub use crate::errno;
+
 pub use crate::qdev::DeviceMethods;
 
 pub use crate::qom::InterfaceType;
