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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bindings/rust-examples/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[workspace]
members = [
"client-hello-config-resolution",
"hyper-server-client", "key-logging",
"hyper-server-client", "io-callbacks", "key-logging",
"tokio-server-client",
]
resolver = "2"
Expand Down
15 changes: 15 additions & 0 deletions bindings/rust-examples/io-callbacks/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "io-callbacks"
version.workspace = true
authors.workspace = true
publish.workspace = true
license.workspace = true
edition.workspace = true

[dependencies]
errno = "0.3.14"
libc = "0.2.189"
tracing = "0.1.44"
s2n-tls = { path = "../../rust/extended/s2n-tls" }

[dev-dependencies]
1 change: 1 addition & 0 deletions bindings/rust-examples/io-callbacks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This example shows how to work with lower-level s2n-tls IO. Most library consumers will not have to work with these methods, and should instead use `s2n-tls-tokio` for higher level IO interfaces.
266 changes: 266 additions & 0 deletions bindings/rust-examples/io-callbacks/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

//! This example shows how to setup the unsafe send + recv callbacks for s2n-tls.

@jouho jouho Aug 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these new files just need the copyright/licence headers. Otherwise looks good to me

//!
//! For progress on offering safe bindings here, follow https://github.com/aws/s2n-tls/issues/6018

pub mod raw_fd;
pub mod tls_stream;

////////////////////////////////////////////////////////////////////////////////
///////////////////// generic Read & Write C callbacks /////////////////////////
////////////////////////////////////////////////////////////////////////////////

use std::ffi::{c_int, c_void};

/// An s2n-tls `send` callback.
///
/// This callback assumes that the underlying IO object follows posix conventions.
/// E.g. a non-blocking send should set the errno to `EWOULDBLOCK` if the send would
/// block.
///
/// Most abstractions, e.g. [`std::net::TcpStream`] already do this.
///
/// This can be used where ctx is a stable pointer to a `T: Write`. For example.
/// ```
/// use std::os::unix::net::UnixStream;
/// use std::pin::Pin;
/// use std::ffi::c_void;
/// use s2n_tls::connection::Connection;
/// use io_callbacks::generic_posix_send_cb;
///
/// let (client_stream, server_stream) = UnixStream::pair().unwrap();

@maddeleine maddeleine Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need this doc example if you have a test example further on. Just seems weird since the send callback has a doc example but the recv callback does not.

/// // The IO context should be pinned, because s2n-tls holds the raw pointer for
/// // the duration of the connection.
/// let io_context: Pin<Box<UnixStream>> = Box::pin(client_stream);
/// let io_ctx_ptr: *mut c_void = &*io_context as *const UnixStream as *mut c_void;
///
/// let mut conn = Connection::new_client();
/// unsafe { conn.set_send_context(io_ctx_ptr) }.unwrap();
/// conn.set_send_callback(Some(generic_posix_send_cb::<UnixStream>)).unwrap();
/// ```
///
/// # Safety
///
/// * `context` must be a stable (`Pin`) pointer to a `T` that outlives the
/// connection.
/// * The callback forms a `&mut T` from `context`, so no other reference to that
/// `T` may be live while it runs. s2n-tls calls the send/receive callbacks
/// one at a time and never reentrantly per connection, so one context may back
/// both callbacks of the same connection. It must not be shared across
/// connections or driven concurrently.
pub unsafe extern "C" fn generic_posix_send_cb<T: std::io::Write>(
context: *mut c_void,
data: *const u8,
len: u32,
) -> c_int {
let context: &mut T = &mut *(context as *mut T);
let data = core::slice::from_raw_parts(data, len as _);
match context.write(data) {
Ok(bytes_written) => bytes_written as i32,
Err(err) => {
// On -1, s2n-tls reads `errno` to distinguish "would block"
// (EWOULDBLOCK/EAGAIN -> Poll::Pending) from a fatal error, so set it
// before returning. Types that hit the syscall directly (e.g.
// std::net::TcpStream) already leave errno set, making this redundant;
// but in general the OS error lives in the io::Error, and intervening
// work (like the log below) can clobber errno. So re-install it last.
let os_err = err.raw_os_error();
tracing::trace!("generic send cb: write error: {err}");
match os_err {
Some(os_err) => errno::set_errno(errno::Errno(os_err)),
None => tracing::warn!("Err {err} doesn't have a corresponding os err 😬"),
}
-1
}
}
}

/// This callback can be used where ctx is a stable pointer to a `T: Read`.
///
/// The underlying transport stream is responsible for populating the errno appropriately.
///
/// A read of `0` is assumed to mean a closed stream. In the case of no data available
/// and a non-blocking IO mode, the io stream should return an Err and set the errno
/// to EWOULDBLOCK.
///
/// # Safety
///
/// * `context` must be a stable (`Pin`) pointer to a `T` that outlives the
/// connection.
/// * The callback forms a `&mut T` from `context`, so no other reference to that
/// `T` may be live while it runs. s2n-tls calls the send/receive callbacks
/// one at a time and never reentrantly per connection, so one context may back
/// both callbacks of the same connection. It must not be shared across
/// connections or driven concurrently.
pub unsafe extern "C" fn generic_posix_recv_cb<T: std::io::Read>(
context: *mut c_void,
data: *mut u8,
len: u32,
) -> c_int {
let context: &mut T = &mut *(context as *mut T);
let data = core::slice::from_raw_parts_mut(data, len as _);
let read_result = context.read(data);
match read_result {
Ok(len) => {
// Note: an in-memory channel (e.g. VecDeque<u8>) returns Ok(0) when
// empty, but s2n-tls treats a read of 0 as EOF. Such transports must
// special-case 0 into an EWOULDBLOCK error instead.
len as c_int
}
Err(err) => {
// On -1, s2n-tls reads `errno` to distinguish "would block"
// (EWOULDBLOCK/EAGAIN -> Poll::Pending) from a fatal error, so set it
// before returning. Types that hit the syscall directly (e.g.
// std::net::TcpStream) already leave errno set, making this redundant;
// but in general the OS error lives in the io::Error, and intervening
// work (like the log below) can clobber errno. So re-install it last.
let os_err = err.raw_os_error();
tracing::trace!("generic recv cb: read error: {err}");
match os_err {
Some(os_err) => errno::set_errno(errno::Errno(os_err)),
None => tracing::warn!("Err {err} doesn't have a corresponding os err 😬"),
}
-1
}
}
}

#[cfg(test)]
pub(crate) mod test_utils {
use s2n_tls::{callbacks::VerifyHostNameCallback, config::Config, security::DEFAULT_TLS13};

// NOTE: these certificates are for demonstration/testing purposes only!
const CA_CERT: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../certs/ca-cert.pem"));
const SERVER_CHAIN: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../certs/localhost-chain.pem"
));
const SERVER_KEY: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../certs/localhost-key.pem"
));
pub(crate) const SERVER_NAME: &str = "localhost";

/// A host verification callback that only trusts the expected server name.
struct VerifyLocalhost;
impl VerifyHostNameCallback for VerifyLocalhost {
fn verify_host_name(&self, host_name: &str) -> bool {
host_name == SERVER_NAME
}
}

pub(crate) fn client_config() -> Config {
let mut builder = Config::builder();
builder.set_security_policy(&DEFAULT_TLS13).unwrap();
builder.trust_pem(CA_CERT).unwrap();
builder.set_verify_host_callback(VerifyLocalhost).unwrap();
builder.build().unwrap()
}

pub(crate) fn server_config() -> Config {
let mut builder = Config::builder();
builder.set_security_policy(&DEFAULT_TLS13).unwrap();
builder.load_pem(SERVER_CHAIN, SERVER_KEY).unwrap();
builder.build().unwrap()
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{client_config, server_config, SERVER_NAME};
use s2n_tls::{connection::Connection, enums::Mode, error::Error as S2NError};
use std::{os::unix::net::UnixStream, task::Poll};

// s2n-tls handshake driven over a std::os::unix::net::UnixStream pair, using
// the generic send/recv callbacks defined in this crate.
#[test]
fn handshake_over_unix_domain_socket() -> Result<(), S2NError> {
let (client_stream, server_stream) = UnixStream::pair().unwrap();

// conceptually, we require a "Pin" because s2n-tls is holding the raw
// context pointer for the lifetime of the connection
let server_stream = Box::pin(server_stream);
let client_stream = Box::pin(client_stream);

let mut server = {
let server_config = server_config();
let mut conn = Connection::new(Mode::Server);
conn.set_config(server_config)?;

let io_context = &*server_stream as *const UnixStream as *mut c_void;

unsafe { conn.set_send_context(io_context) }?;
conn.set_send_callback(Some(generic_posix_send_cb::<UnixStream>))?;

unsafe { conn.set_receive_context(io_context) }?;
conn.set_receive_callback(Some(generic_posix_recv_cb::<UnixStream>))?;

conn
};

let mut client = {
let server_config = client_config();
let mut conn = Connection::new(Mode::Client);
conn.set_config(server_config)?;
conn.set_server_name(SERVER_NAME)?;

let io_context = &*client_stream as *const UnixStream as *mut c_void;

unsafe { conn.set_send_context(io_context) }?;
conn.set_send_callback(Some(generic_posix_send_cb::<UnixStream>))?;

unsafe { conn.set_receive_context(io_context) }?;
conn.set_receive_callback(Some(generic_posix_recv_cb::<UnixStream>))?;

conn
};

// Drive each handshake on its own thread.
//
// These sockets are blocking, so a stalled callback blocks the thread
// instead of returning EWOULDBLOCK; poll_negotiate never yields Pending
// and the loops below don't spin. The peers need separate threads, or one
// would block waiting for bytes the other never gets to send. With
// non-blocking IO a single thread can drive both, but should wait on the
// fd (poll/select) or a waker rather than spinning on Pending.

// drive the client handshake
let client_hs = std::thread::spawn(move || {
let res = loop {
match client.poll_negotiate() {
Poll::Ready(res) => break res,
Poll::Pending => { /* we need to poll again */ }
};
};
assert!(res.is_ok());
client
});

// drive the server handshake
let server_hs = std::thread::spawn(move || {
let res = loop {
match server.poll_negotiate() {
Poll::Ready(res) => break res,
Poll::Pending => { /* we need to poll again */ }
};
};
assert!(res.is_ok());
server
});

client_hs.join().unwrap();
server_hs.join().unwrap();

// Note that because s2n-tls takes raw pointers to the underlying stream
// there is no automatic memory management. It is generally easier to
// implement a `TlsStream` abstraction that store the Connection alongside
// it's "owned" transport layer.
drop(client_stream);
drop(server_stream);
Ok(())
}
}
Loading
Loading