rust: bssl-tls: Introduce I/O model

Bug: 479599893

Signed-off-by: Xiangfei Ding <xfding@google.com>
Change-Id: I26f09e3b18a446c7cf7cf180059bcf046a6a6964
Reviewed-on: https://boringssl-review.googlesource.com/c/boringssl/+/91407
Reviewed-by: Adam Langley <agl@google.com>
diff --git a/rust/bssl-tls/src/connection.rs b/rust/bssl-tls/src/connection.rs
index 39dd454..7944996 100644
--- a/rust/bssl-tls/src/connection.rs
+++ b/rust/bssl-tls/src/connection.rs
@@ -16,6 +16,7 @@
 
 use alloc::boxed::Box;
 use core::{
+    ffi::c_int,
     marker::PhantomData,
     mem::transmute,
     ops::{Deref, DerefMut},
@@ -24,12 +25,17 @@
 };
 
 use crate::{
-    config::ProtocolVersion, connection::methods::waker_data_ref_from_ssl, context::TlsMode,
+    config::ProtocolVersion,
+    connection::methods::waker_data_ref_from_ssl,
+    context::TlsMode,
+    errors::{Error, TlsRetryReason},
+    io::IoStatus,
 };
 
 mod credentials;
 pub mod lifecycle;
 pub(crate) mod methods;
+pub mod transport;
 
 /// Server role - the connection runs as a server.
 pub enum Server {}
@@ -108,7 +114,26 @@
     }
 }
 
-#[allow(unused)]
+impl<R, M> TlsConnectionRef<R, M> {
+    #[allow(unused)]
+    pub(crate) fn categorise_error_for_io(&self, rc: c_int) -> Result<IoStatus, Error> {
+        let reason = unsafe {
+            // Safety: we only want to extract the last I/O error on an existing valid connection.
+            bssl_sys::SSL_get_error(self.ptr(), rc)
+        };
+        let res = match TlsRetryReason::try_from(reason) {
+            Ok(TlsRetryReason::PeerCloseNotify) => Ok(IoStatus::EndOfStream),
+            Ok(reason) => Ok(IoStatus::Retry(reason)),
+            Err(_) => Err(Error::extract_lib_err()),
+        };
+        unsafe {
+            // Safety: we only clear the error on the current thread.
+            bssl_sys::ERR_clear_error();
+        }
+        res
+    }
+}
+
 impl<R, M> TlsConnectionRef<R, M>
 where
     M: methods::HasTlsConnectionMethod,
@@ -146,6 +171,8 @@
         } else {
             *waker_data = Some(waker.clone());
         }
+        let methods = self.get_connection_methods();
+        methods.set_waker(waker);
     }
 }
 
diff --git a/rust/bssl-tls/src/connection/lifecycle.rs b/rust/bssl-tls/src/connection/lifecycle.rs
index 6086290..a0ec5bd 100644
--- a/rust/bssl-tls/src/connection/lifecycle.rs
+++ b/rust/bssl-tls/src/connection/lifecycle.rs
@@ -84,6 +84,14 @@
     }
 }
 
+impl<R> TlsConnectionRef<R, TlsMode> {
+    /// Inspect if the connection is suspended for which reason, after invocation of I/O methods.
+    pub fn take_pending_reason(&mut self) -> Option<TlsRetryReason> {
+        let methods = self.get_connection_methods();
+        methods.take_pending_reason()
+    }
+}
+
 /// A handle to the connection that is valid only during handshake.
 #[repr(transparent)]
 pub struct TlsConnectionInHandshake<'a, R, M>(pub(crate) &'a mut TlsConnectionRef<R, M>);
diff --git a/rust/bssl-tls/src/connection/methods.rs b/rust/bssl-tls/src/connection/methods.rs
index 498ee4b..d883d60 100644
--- a/rust/bssl-tls/src/connection/methods.rs
+++ b/rust/bssl-tls/src/connection/methods.rs
@@ -25,17 +25,38 @@
 use crate::{
     Methods, abort_on_panic,
     context::{QuicMode, TlsMode},
+    errors::TlsRetryReason,
+    io::RustBioHandle,
     methods::drop_box_rust_methods,
 };
 
 /// The associated state to the [`super::TlsConnection`].
 pub(super) struct RustConnectionMethods<Mode> {
+    /// A handle to a `BIO` managed by this crate.
+    pub bio: Option<RustBioHandle>,
+    /// A mailbox to propagate IO retrying reasons.
+    pub pending_reason: Option<TlsRetryReason>,
     _p: PhantomData<fn() -> Mode>,
 }
 
 impl<M> RustConnectionMethods<M> {
     pub fn new() -> Self {
-        Self { _p: PhantomData }
+        Self {
+            bio: None,
+            pending_reason: None,
+            _p: PhantomData,
+        }
+    }
+
+    pub fn take_pending_reason(&mut self) -> Option<TlsRetryReason> {
+        self.pending_reason.take()
+    }
+
+    /// Propagate waker to the handlers.
+    pub fn set_waker(&mut self, waker: &Waker) {
+        if let Some(bio) = &mut self.bio {
+            bio.set_waker(waker);
+        }
     }
 }
 
diff --git a/rust/bssl-tls/src/connection/transport.rs b/rust/bssl-tls/src/connection/transport.rs
new file mode 100644
index 0000000..0c3a694
--- /dev/null
+++ b/rust/bssl-tls/src/connection/transport.rs
@@ -0,0 +1,88 @@
+// Copyright 2026 The BoringSSL Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! TLS Connection transport settings
+//!
+
+use crate::{
+    connection::TlsConnectionRef,
+    context::TlsMode,
+    errors::Error,
+    io::{AbstractReader, AbstractSocket, AbstractWriter, RustBio},
+};
+
+/// # Transport configurations
+///
+/// These are the methods to configure the underlying IO drivers and transport configurations.
+impl<R> TlsConnectionRef<R, TlsMode> {
+    /// Set up underlying transport driver.
+    pub fn set_io<S: 'static + AbstractSocket>(&mut self, socket: S) -> Result<&mut Self, Error> {
+        let bio = RustBio::new_duplex(socket)?;
+        unsafe {
+            // Safety: the additional ref-count is to compensate for `SSL` taking ownership.
+            bssl_sys::BIO_up_ref(bio.ptr());
+            // Safety: the `bio` pointer has been sanitised and `self.0` is still valid.
+            bssl_sys::SSL_set_bio(self.ptr(), bio.ptr(), bio.ptr());
+        }
+        let methods = self.get_connection_methods();
+        methods.bio = Some(bio);
+        Ok(self)
+    }
+
+    /// Set up underlying transport driver, with a pair of read and write ends.
+    pub fn set_split_io<Reader, Writer>(
+        &mut self,
+        read: Reader,
+        write: Writer,
+    ) -> Result<&mut Self, Error>
+    where
+        Reader: 'static + AbstractReader,
+        Writer: 'static + AbstractWriter,
+    {
+        let bio = RustBio::new_split(read, write)?;
+        unsafe {
+            // Safety: the additional ref-count is to compensate for `SSL` taking ownership.
+            bssl_sys::BIO_up_ref(bio.ptr());
+            // Safety: the `bio` pointer has been sanitised and `self.0` is still valid.
+            bssl_sys::SSL_set_bio(self.ptr(), bio.ptr(), bio.ptr());
+        }
+        let methods = self.get_connection_methods();
+        methods.bio = Some(bio);
+        Ok(self)
+    }
+
+    /// Check if the underlying **transport** has closed its write end.
+    pub fn is_write_closed(&self) -> bool {
+        self.get_connection_methods_ref()
+            .bio
+            .as_ref()
+            .map_or(true, |bio| bio.as_ref().write_eos)
+    }
+
+    /// Check if the underlying **transport** has closed its read end.
+    pub fn is_read_closed(&self) -> bool {
+        self.get_connection_methods_ref()
+            .bio
+            .as_ref()
+            .map_or(true, |bio| bio.as_ref().read_eos)
+    }
+
+    /// Check if the underlying **transport** has closed either its read end or its write end.
+    pub fn is_one_side_closed(&self) -> bool {
+        self.get_connection_methods_ref()
+            .bio
+            .as_ref()
+            .map_or(true, |bio| bio.as_ref().read_eos || bio.as_ref().write_eos)
+    }
+}
diff --git a/rust/bssl-tls/src/ffi.rs b/rust/bssl-tls/src/ffi.rs
index 76fd7d8..dfa7d41 100644
--- a/rust/bssl-tls/src/ffi.rs
+++ b/rust/bssl-tls/src/ffi.rs
@@ -12,7 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-use core::ptr::null;
+use core::{
+    ptr::null,
+    slice::{from_raw_parts, from_raw_parts_mut},
+};
 
 /// We follow the convention in [`bssl_crypto::FfiSlice`] by which we signal empty arrays as `null`.
 /// An empty Rust slice has a non-zero address value, out of the necessity of enabling pointer-niche
@@ -25,3 +28,47 @@
         (slice.as_ptr(), slice.len())
     }
 }
+
+/// Sanitize the data pointer and length and reconstitute the slice.
+///
+/// This method returns an empty slice if the length is 0 or the pointer is NULL.
+/// # Safety
+/// Caller must ensure that `'a` outlives `input`.
+#[inline]
+pub(crate) unsafe fn sanitize_slice<'a, T>(input: *const T, len: usize) -> Option<&'a [T]> {
+    if len == 0 || input.is_null() {
+        return Some(&[]);
+    }
+    if !input.is_aligned() || len.checked_mul(size_of::<T>())? >= isize::MAX as usize {
+        return None;
+    }
+    unsafe {
+        // Safety: the pointer and the size has been sanitised.
+        Some(from_raw_parts(input, len))
+    }
+}
+
+/// Sanitize the data pointer and length and reconstitute the mutable slice.
+///
+/// `capacity` counts the number of `T`s that `out` can hold, **not number of bytes**.
+///
+/// This method returns an empty slice if the length is 0 or the pointer is NULL.
+/// # Safety
+/// Caller must ensure that `'a` outlives `input`.
+#[inline]
+pub(crate) unsafe fn sanitise_mut_slice<'a, T>(
+    out: *mut T,
+    capacity: usize,
+) -> Option<&'a mut [T]> {
+    if capacity == 0 || out.is_null() || !out.is_aligned() {
+        return Some(&mut []);
+    }
+    if capacity.checked_mul(size_of::<T>())? >= isize::MAX as usize {
+        return None;
+    }
+    unsafe {
+        // Safety: `out` is 1-aligned and `0` is a valid pattern for `u8`.
+        core::ptr::write_bytes(out, 0, capacity);
+        Some(from_raw_parts_mut(out, capacity))
+    }
+}
diff --git a/rust/bssl-tls/src/io.rs b/rust/bssl-tls/src/io.rs
new file mode 100644
index 0000000..0204ef2
--- /dev/null
+++ b/rust/bssl-tls/src/io.rs
@@ -0,0 +1,532 @@
+// Copyright 2026 The BoringSSL Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! TLS I/O model
+
+use alloc::boxed::Box;
+use core::{
+    ffi::{CStr, c_char, c_int, c_long, c_void},
+    fmt,
+    ptr::NonNull,
+    task::{Context, Waker},
+};
+
+use once_cell::sync::Lazy;
+
+use crate::{
+    abort_on_panic,
+    errors::{Error, TlsRetryReason},
+    ffi::{sanitise_mut_slice, sanitize_slice},
+};
+
+/// A wrapper around a `dyn AbstractSocket`, delegating BIO methods to the
+/// underlying `AbstractSocket` implementations.
+///
+/// # Safety
+///
+/// [`RustBio`] can only be accessed through exclusive ownership.
+// TODO(@xfding): switch to `derive(CoercePointee)` when it stabilises for flattening the layout.
+pub(crate) struct RustBio {
+    // We may need to propagate the waker, but nothing more.
+    waker: Option<Waker>,
+    pub read_eos: bool,
+    pub write_eos: bool,
+    socket: Option<Box<dyn AbstractSocket>>,
+    reader: Option<Box<dyn AbstractReader>>,
+    writer: Option<Box<dyn AbstractWriter>>,
+    io_err: Option<Box<dyn core::error::Error + Send + Sync>>,
+}
+
+/// Safety: `socket` field is a exclusively owned `Box<dyn AbstractSocket>` pointer,
+/// and `AbstractSocket: Send + Sync`.
+unsafe impl Send for RustBio {}
+unsafe impl Sync for RustBio {}
+
+fn _assert_rust_bio()
+where
+    RustBio: Send + Unpin,
+{
+}
+
+/// Purely module-internal implementation details
+impl RustBio {
+    fn init() -> Self {
+        Self {
+            waker: None,
+            socket: None,
+            reader: None,
+            writer: None,
+            read_eos: false,
+            write_eos: false,
+            io_err: None,
+        }
+    }
+
+    pub fn attach_socket(&mut self, socket: Box<dyn AbstractSocket>) {
+        self.socket = Some(socket);
+    }
+
+    pub fn attach_reader(&mut self, reader: Box<dyn AbstractReader>) {
+        self.reader = Some(reader);
+    }
+
+    pub fn attach_writer(&mut self, writer: Box<dyn AbstractWriter>) {
+        self.writer = Some(writer);
+    }
+
+    pub fn get_reader(&mut self) -> Option<&mut dyn AbstractReader> {
+        if let Some(reader) = &mut self.reader {
+            Some(&mut **reader)
+        } else if let Some(socket) = &mut self.socket {
+            Some(&mut **socket)
+        } else {
+            None
+        }
+    }
+
+    pub fn get_writer(&mut self) -> Option<&mut dyn AbstractWriter> {
+        if let Some(writer) = &mut self.writer {
+            Some(&mut **writer)
+        } else if let Some(socket) = &mut self.socket {
+            Some(&mut **socket)
+        } else {
+            None
+        }
+    }
+}
+
+/// Crate-level `BIO` implementation details
+impl RustBio {
+    pub fn set_waker(&mut self, waker: &Waker) {
+        if let Some(other_waker) = &self.waker
+            && waker.will_wake(other_waker)
+        {
+            return;
+        }
+        self.waker = Some(waker.clone());
+    }
+
+    pub fn new_duplex<T: 'static + AbstractSocket + Sized>(
+        socket: T,
+    ) -> Result<RustBioHandle, Error> {
+        let bio = unsafe {
+            // Safety: the BIO_METH will be valid if this is the first call.
+            bssl_sys::BIO_new(get_bio_method())
+        };
+        let bio = NonNull::new(bio).expect("allocation failure");
+        unsafe {
+            // Safety: `bio` was constructed with our own BIO_METH, so the data must be valid
+            // as a `RustBio`
+            rust_bio_data_mut(bio.as_ptr()).attach_socket(Box::new(socket));
+        }
+        Ok(RustBioHandle(bio))
+    }
+
+    pub fn new_split<R, W>(reader: R, writer: W) -> Result<RustBioHandle, Error>
+    where
+        R: 'static + AbstractReader + Sized,
+        W: 'static + AbstractWriter + Sized,
+    {
+        let bio = unsafe {
+            // Safety: the BIO_METH will be valid if this is the first call.
+            bssl_sys::BIO_new(get_bio_method())
+        };
+        let bio = NonNull::new(bio).expect("allocation failure");
+        let data = unsafe {
+            // Safety: `bio` was constructed with our own BIO_METH, so the data must be valid
+            // as a `RustBio`
+            rust_bio_data_mut(bio.as_ptr())
+        };
+        data.attach_reader(Box::new(reader));
+        data.attach_writer(Box::new(writer));
+        Ok(RustBioHandle(bio))
+    }
+
+    fn transform_result(
+        &mut self,
+        res: AbstractSocketResult,
+        reason_on_retry: TlsRetryReason,
+    ) -> IoStatus {
+        match res {
+            AbstractSocketResult::Ok(bytes) => IoStatus::Ok(bytes),
+            AbstractSocketResult::Retry => IoStatus::Retry(reason_on_retry),
+            AbstractSocketResult::EndOfStream => IoStatus::EndOfStream,
+            AbstractSocketResult::Err(e) => {
+                self.io_err = Some(e);
+                IoStatus::Err
+            }
+        }
+    }
+}
+
+/// A exclusively owned handle to a BIO constructed by this crate.
+pub(crate) struct RustBioHandle(NonNull<bssl_sys::BIO>);
+
+impl RustBioHandle {
+    pub fn ptr(&self) -> *mut bssl_sys::BIO {
+        self.0.as_ptr()
+    }
+
+    pub fn as_mut(&mut self) -> &mut RustBio {
+        unsafe {
+            // Safety: `self` witnesses the validity of the handle.
+            rust_bio_data_mut(self.ptr())
+        }
+    }
+
+    pub fn as_ref(&self) -> &RustBio {
+        unsafe {
+            // Safety: `self` witnesses the validity of the handle.
+            rust_bio_data(self.ptr())
+        }
+    }
+
+    pub fn set_waker(&mut self, waker: &Waker) {
+        self.as_mut().set_waker(waker);
+    }
+}
+
+impl Drop for RustBioHandle {
+    fn drop(&mut self) {
+        unsafe {
+            // Safety: the BIO handle should still be valid and was created by this crate.
+            bssl_sys::BIO_free(self.0.as_ptr());
+        }
+    }
+}
+
+/// Safety: caller must ensure that `bio` is created with `rust_bio_create` and outlives `'a`
+/// for exclusive access.
+unsafe fn rust_bio_data_mut<'a>(bio: *mut bssl_sys::BIO) -> &'a mut RustBio {
+    let data = unsafe {
+        // Safety: `bio` is still valid
+        bssl_sys::BIO_get_data(bio)
+    };
+    unsafe { &mut *(data as *mut RustBio) }
+}
+
+/// Safety: caller must ensure that `bio` is created with `rust_bio_create` and outlives `'a`
+/// for shared access.
+unsafe fn rust_bio_data<'a>(bio: *mut bssl_sys::BIO) -> &'a RustBio {
+    let data = unsafe {
+        // Safety: `bio` is still valid
+        bssl_sys::BIO_get_data(bio)
+    };
+    unsafe { &*(data as *const RustBio) }
+}
+
+/// I/O Status of the possibly pending operation.
+#[derive(Debug, Clone, Copy)]
+pub enum IoStatus {
+    /// Successfully performed I/O of bytes at certain size.
+    Ok(usize),
+    /// There is no more data to read or write.
+    EndOfStream,
+    /// I/O operation should be retried with the exactly same buffers when applicable.
+    Retry(TlsRetryReason),
+    /// There is no backing socket.
+    Empty,
+    /// I/O operation has failed.
+    Err,
+}
+
+/// Result of operating an [`AbstractSocket`].
+pub enum AbstractSocketResult {
+    /// I/O completed by committing some amount of bytes.
+    Ok(usize),
+    /// I/O is pending completion; the I/O operation should be invoked again with the same parameter.
+    Retry,
+    /// I/O is impossible because the stream has ended.
+    EndOfStream,
+    /// I/O operation failed.
+    Err(Box<dyn core::error::Error + Send + Sync>),
+}
+
+/// Abstract reader.
+pub trait AbstractReader: Send + Sync + Unpin {
+    /// Read data from the socket.
+    fn read(
+        &mut self,
+        async_ctx: Option<&mut Context<'_>>,
+        buffer: &mut [u8],
+    ) -> AbstractSocketResult;
+}
+
+/// Abstract writer.
+pub trait AbstractWriter: Send + Sync + Unpin {
+    /// Write data to the socket.
+    fn write(&mut self, async_ctx: Option<&mut Context<'_>>, buffer: &[u8])
+    -> AbstractSocketResult;
+    /// Flush the socket.
+    fn flush(&mut self, async_ctx: Option<&mut Context<'_>>) -> AbstractSocketResult;
+}
+
+/// Abstract socket wrapper around Rust types that may support async I/O.
+pub trait AbstractSocket: AbstractReader + AbstractWriter {}
+
+// NOTE: this is not dead code, we are asserting that `dyn AbstractSocket` is a well-formed type,
+// or `AbstractSocket` is dyn-compatible specifically.
+fn _assert_dyn_compat()
+where
+    dyn AbstractSocket:,
+{
+}
+
+fn get_bio_type() -> c_int {
+    static BIO_TYPE: Lazy<c_int> = Lazy::new(|| {
+        // Safety: this call does not have side-effect other than ID assignment
+        unsafe { bssl_sys::BIO_get_new_index() }
+    });
+    *BIO_TYPE
+}
+
+struct BioMethod(*mut bssl_sys::BIO_METHOD);
+/// Safety: once constructed this BIO vtable will stay immutable.
+unsafe impl Send for BioMethod {}
+/// Safety: once constructed this BIO vtable will stay immutable.
+unsafe impl Sync for BioMethod {}
+
+fn get_bio_method() -> *const bssl_sys::BIO_METHOD {
+    static BIO_METHOD: Lazy<BioMethod> = Lazy::new(|| {
+        let cstr = const {
+            if let Ok(cstr) = CStr::from_bytes_with_nul(b"rust_bio\0") {
+                cstr
+            } else {
+                // Compile-time assertion
+                unreachable!()
+            }
+        };
+        let vtable = unsafe {
+            // Safety: this call does not have side-effect other than allocation.
+            bssl_sys::BIO_meth_new(get_bio_type(), cstr.as_ptr())
+        };
+        unsafe {
+            // Safety: all the following calls are simple assignments to the vtable entries.
+            bssl_sys::BIO_meth_set_read(vtable, Some(rust_bio_read));
+            bssl_sys::BIO_meth_set_write(vtable, Some(rust_bio_write));
+            bssl_sys::BIO_meth_set_ctrl(vtable, Some(rust_bio_ctrl));
+            bssl_sys::BIO_meth_set_create(vtable, Some(rust_bio_create));
+            bssl_sys::BIO_meth_set_destroy(vtable, Some(rust_bio_destroy));
+        }
+        BioMethod(vtable)
+    });
+    BIO_METHOD.0
+}
+
+unsafe extern "C" fn rust_bio_read(
+    bio: *mut bssl_sys::BIO,
+    buffer: *mut c_char,
+    len: c_int,
+) -> c_int {
+    let rust_bio = unsafe {
+        // Safety: `bio` is still valid and so is the `RustBio` which we have exclusive access to.
+        rust_bio_data_mut(bio)
+    };
+    if rust_bio.read_eos {
+        return 0;
+    }
+    let Ok(len) = usize::try_from(len) else {
+        return -1;
+    };
+    let waker = rust_bio.waker.clone();
+    let mut async_ctx = if let Some(waker) = &waker {
+        Some(Context::from_waker(waker))
+    } else {
+        None
+    };
+    // Zero the buffer now.
+    // TODO(@xfding): maybe we want to have a buffer wrapper that tracks initialised region.
+    let Some(buf) = (unsafe {
+        // Safety: `buffer` is valid for holding `len` bytes by BoringSSL invariants.
+        buffer.write_bytes(0, len);
+        // Safety: `buffer` and `len` are sanitised and initialised for the right memory region.
+        sanitise_mut_slice(buffer as *mut u8, len)
+    }) else {
+        return -1;
+    };
+    let work = {
+        let Some(reader) = rust_bio.get_reader() else {
+            return -1;
+        };
+        move || reader.read(async_ctx.as_mut(), buf)
+    };
+    let res = abort_on_panic(work);
+    match rust_bio.transform_result(res, TlsRetryReason::WantRead) {
+        IoStatus::Ok(bytes) => {
+            if let Ok(bytes) = c_int::try_from(bytes) {
+                return bytes;
+            }
+            -1
+        }
+        IoStatus::EndOfStream => {
+            rust_bio.read_eos = true;
+            -1
+        }
+        IoStatus::Retry(_) => {
+            unsafe {
+                // Safety: `bio` is still valid now.
+                bssl_sys::BIO_set_retry_read(bio);
+            }
+            -1
+        }
+        IoStatus::Empty | IoStatus::Err => -1,
+    }
+}
+
+unsafe extern "C" fn rust_bio_write(
+    bio: *mut bssl_sys::BIO,
+    buffer: *const c_char,
+    len: c_int,
+) -> c_int {
+    let rust_bio = unsafe {
+        // Safety: `bio` is still valid and so is the `RustBio` which we have exclusive access to.
+        rust_bio_data_mut(bio)
+    };
+    if rust_bio.write_eos {
+        return 0;
+    }
+    let Ok(len) = usize::try_from(len) else {
+        return -1;
+    };
+    let waker = rust_bio.waker.clone();
+    let mut async_ctx = if let Some(waker) = &waker {
+        Some(Context::from_waker(waker))
+    } else {
+        None
+    };
+    let Some(buf) = (unsafe {
+        // Safety: `buffer` and `len` are sanitised and initialised for the right memory region.
+        sanitize_slice(buffer as *mut u8, len)
+    }) else {
+        return -1;
+    };
+    let work = {
+        let Some(writer) = rust_bio.get_writer() else {
+            return -1;
+        };
+        move || writer.write(async_ctx.as_mut(), buf)
+    };
+    let res = abort_on_panic(work);
+    match rust_bio.transform_result(res, TlsRetryReason::WantWrite) {
+        IoStatus::Ok(bytes) => {
+            if let Ok(bytes) = c_int::try_from(bytes) {
+                return bytes;
+            }
+            -1
+        }
+        IoStatus::EndOfStream => {
+            rust_bio.write_eos = true;
+            0
+        }
+        IoStatus::Retry(_) => {
+            unsafe {
+                // Safety: `bio` is still valid now.
+                bssl_sys::BIO_set_retry_write(bio);
+            }
+            -1
+        }
+        IoStatus::Empty | IoStatus::Err => -1,
+    }
+}
+
+unsafe fn rust_bio_flush(bio: *mut bssl_sys::BIO) -> c_long {
+    let rust_bio = unsafe {
+        // Safety: `bio` is still valid
+        rust_bio_data_mut(bio)
+    };
+    if rust_bio.write_eos {
+        return 0;
+    }
+    let waker = rust_bio.waker.clone();
+    let mut async_ctx = if let Some(waker) = &waker {
+        Some(Context::from_waker(waker))
+    } else {
+        None
+    };
+    let work = {
+        let Some(writer) = rust_bio.get_writer() else {
+            return -1;
+        };
+        move || writer.flush(async_ctx.as_mut())
+    };
+    let res = abort_on_panic(work);
+    match rust_bio.transform_result(res, TlsRetryReason::WantWrite) {
+        IoStatus::Ok(_) | IoStatus::Retry(_) => 1,
+        IoStatus::EndOfStream => {
+            rust_bio.write_eos = true;
+            0
+        }
+        IoStatus::Empty | IoStatus::Err => 0,
+    }
+}
+
+unsafe extern "C" fn rust_bio_ctrl(
+    bio: *mut bssl_sys::BIO,
+    ctrl: c_int,
+    _: c_long,
+    _: *mut c_void,
+) -> c_long {
+    match ctrl {
+        bssl_sys::BIO_CTRL_FLUSH => unsafe {
+            // Safety: `bio` is still valid.
+            rust_bio_flush(bio)
+        },
+        _ => 0,
+    }
+}
+
+unsafe extern "C" fn rust_bio_create(bio: *mut bssl_sys::BIO) -> c_int {
+    let data = Box::new(RustBio::init());
+    let data = Box::into_raw(data);
+    unsafe {
+        // Safety: both `bio` and `data` are still valid and exclusively owned.
+        bssl_sys::BIO_set_data(bio, data as _);
+        // Safety: it is now already safe to mark BIO initialised.
+        bssl_sys::BIO_set_init(bio, 1);
+    }
+    1
+}
+
+unsafe extern "C" fn rust_bio_destroy(bio: *mut bssl_sys::BIO) -> c_int {
+    let rust_bio = unsafe {
+        // Safety: `bio` is still valid
+        bssl_sys::BIO_get_data(bio) as *mut RustBio
+    };
+    let rust_bio = unsafe {
+        // Safety: `rust_bio` is created from `rust_bio_create`
+        Box::from_raw(rust_bio)
+    };
+    // Try to catch unwinding on the FFI boundary.
+    // NOTE: it is not safe to drop the error value because its destructor can panic again.
+    abort_on_panic(move || {
+        let _ = rust_bio;
+    });
+    unsafe {
+        // Safety: `bio` is still valid, we just need to signal that it is inactive.
+        bssl_sys::BIO_set_init(bio, 0);
+    }
+    1
+}
+
+/// Asynchronous methods were invoked outside `async` context
+#[derive(Debug)]
+pub struct NoAsyncContext;
+
+impl core::error::Error for NoAsyncContext {}
+
+impl fmt::Display for NoAsyncContext {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str("async method is called outside async context")
+    }
+}
diff --git a/rust/bssl-tls/src/lib.rs b/rust/bssl-tls/src/lib.rs
index c3b5a33..bbe1fee 100644
--- a/rust/bssl-tls/src/lib.rs
+++ b/rust/bssl-tls/src/lib.rs
@@ -41,6 +41,7 @@
 pub mod credentials;
 pub mod errors;
 mod ffi;
+pub mod io;
 mod methods;
 #[macro_use]
 #[doc(hidden)]