Skip to content

feat: Add connection extension framework with symmetric client-server hooks#316

Open
jiangliu wants to merge 2 commits into
containerd:masterfrom
jiangliu:gerry/secure-extension
Open

feat: Add connection extension framework with symmetric client-server hooks#316
jiangliu wants to merge 2 commits into
containerd:masterfrom
jiangliu:gerry/secure-extension

Conversation

@jiangliu

Copy link
Copy Markdown

Summary

ttrpc currently provides no mechanism for applications to inject connection-level policies such as encryption, authentication, or file-descriptor passthrough. Each RPC message passes through serialization and transport layers with no opportunity for the application to inspect or transform the wire-format bytes, leaving security-critical concerns to be handled out-of-band or not at all.

This PR introduces a symmetric extension framework that exposes 10 injection points across the client and server lifecycle, enabling applications to implement composable security policies (e.g., AES-GCM encryption, Ed25519 authentication) without modifying ttrpc core.

Design Principles

  • ttrpc provides mechanism, not policy. No encryption algorithms, identity types, or authorization logic in ttrpc itself — all decisions reside in the application layer.
  • Symmetric hooks. Both AcceptHook (server) and ConnectHook (client) share the same HookOutput/HookError types and PayloadTransform trait.
  • Zero overhead when unused. No hook → all connections accepted, payload_transform = None → plaintext pass-through.

10 Injection Points

# Side Location Direction Description
1 server do_start() accept AcceptHook::on_accept
2 server handle_request() inbound Unary REQUEST transform_inbound
3 server handle_msg() DATA route Streaming DATA routed without transform
4 server handle_method() response outbound Unary RESPONSE transform_outbound
5 client new_inner() connect ConnectHook::on_connect
6 client request() send outbound Unary REQUEST transform_outbound
7 client handle_msg() recv inbound Unary RESPONSE transform_inbound
8 client new_stream() send outbound Stream-init REQUEST transform_outbound
9 both StreamSender::send() outbound Streaming DATA transform_outbound
10 both StreamReceiver::recv() inbound Streaming DATA transform_inbound

Key design decision: Streaming DATA messages are routed (not transformed) in handle_msg() (#3). The transform is applied exclusively in StreamSender::send() / StreamReceiver::recv() (#9/#10) to avoid double-transform.

streaming_client=false with initial payload

When streaming_client=false and the stream-init REQUEST carries a payload, handle_stream() creates a synthetic DATA message from the already-decrypted REQUEST payload (decrypted at #2). A pub(crate) internal_flags field on GenMessage carries a skip_transform bit so StreamReceiver::recv() (#10) passes it through without re-applying transform_inbound. This works for any PayloadTransform, including asymmetric transforms.

New Public API

// src/extension.rs
pub trait PayloadTransform: Send + Sync + Debug {
    fn transform_inbound(&self, data: Vec<u8>) -> Result<Vec<u8>, String>;
    fn transform_outbound(&self, data: Vec<u8>) -> Result<Vec<u8>, String>;
}

#[cfg(unix)]
pub trait AcceptHook: Send + Sync + Debug {
    fn on_accept(&self, fd: RawFd) -> Result<HookOutput, HookError>;
}

#[cfg(unix)]
pub trait ConnectHook: Send + Sync + Debug {
    fn on_connect(&self, fd: RawFd) -> Result<HookOutput, HookError>;
}

pub struct HookOutput {
    pub data: ConnectionData,
    pub payload_transform: Option<Box<dyn PayloadTransform>>,
}

pub enum HookError { Rejected(String), Timeout, Io(io::Error), Other(String) }

pub struct ConnectionContext { /* Arc-wrapped, immutable after accept */ }

Server registration

Server::new()
    .bind("unix:///run/agent.sock")?
    .set_accept_hook(Box::new(MyAcceptHook))
    .register_service(services)
    .start().await?;

Client registration

let socket = Socket::connect("unix:///run/agent.sock").await?;
let client = Client::with_hook(socket, Box::new(MyConnectHook));

Socket raw_fd Capture

Socket now stores the underlying RawFd (Unix only) so hooks can call getpeername() for peer identity inspection (e.g., vsock CID) and perform bidirectional handshake I/O. Platform-specific From impls (TcpStream, UnixStream, VsockStream) capture the fd via as_raw_fd(); the generic Socket::new() sets raw_fd = None.

Files Changed

File Change
src/extension.rs New — framework types, traits, ConnectionContext, 20 unit tests
tests/hook_integration.rs New — 19 integration tests
src/proto.rs GenMessage::internal_flags + helper constructors
src/lib.rs Re-export HookError, HookOutput, AcceptHook, ConnectHook
src/asynchronous/server.rs set_accept_hook(), on_accept() in accept loop, injection points #2/#3/#4
src/asynchronous/client.rs with_hook(), ConnectHook in new_inner(), injection points #6/#7/#8
src/asynchronous/stream.rs payload_transform field on sender/receiver, injection points #9/#10
src/asynchronous/transport/mod.rs Socket gains raw_fd: Option<RawFd>, Socket::from() captures fd
src/asynchronous/transport/{tcp,unix,vsock}.rs From impls capture as_raw_fd()
src/asynchronous/utils.rs TtrpcContext::connection_data field

Test Coverage

87 tests (67 unit + 19 integration + 1 example):

  • Payload transform roundtrip (XOR-0xA5A5 mock encryption): basic, empty, odd-length, large, header verification, error handling
  • ConnectionContext construction, Arc sharing, transform pass-through
  • Hook invocation, rejection, fd capture
  • End-to-end unary + streaming with XOR transform
  • streaming_client=false with initial payload (skip_transform path)
  • streaming_server=false DATA message rejection
  • Multiple concurrent connections with transform
  • Multiple concurrent streams on one connection
  • Server shutdown during active stream
  • Unary request timeout

Backward Compatibility

Scenario Behavior
No hook set All connections accepted, empty data, no transform
payload_transform = None Plaintext pass-through (no overhead)
Existing handlers connection_data is empty HashMap — no breakage
Wire-protocol header Unchanged — transform operates on payload bytes after framing

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a symmetric connection extension framework to ttrpc’s async client/server stack, enabling applications to attach per-connection metadata and optionally transform payload bytes (e.g., for encryption/auth) via hooks and a PayloadTransform.

Changes:

  • Introduces a new extension module with hook/transform traits, connection context, and typed connection metadata propagation.
  • Wires the connection context through async server/client/stream pipelines, applying transforms at defined injection points and adding a skip_transform internal flag for synthetic stream DATA.
  • Extends async transport Socket to capture Unix raw_fd so hooks can inspect peers and perform handshake I/O.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/hook_integration.rs Adds end-to-end integration tests for hooks/transforms over Unix sockets.
src/extension.rs New core extension framework types (hooks, transform trait, connection context) plus unit tests.
src/lib.rs Exposes the new extension module and re-exports key extension APIs.
src/proto.rs Adds non-wire internal_flags to GenMessage and helper constructors for DATA/RESPONSE/close.
src/asynchronous/utils.rs Adds connection_data to TtrpcContext for handler access.
src/asynchronous/server.rs Installs accept hook, propagates connection context, and applies transforms in server pipeline.
src/asynchronous/client.rs Adds Client::with_hook, runs connect hook, and applies transforms in client pipeline.
src/asynchronous/stream.rs Applies transforms for streaming DATA send/recv with skip-transform support.
src/asynchronous/transport/mod.rs Refactors Socket to store Unix raw_fd and exposes as_raw_fd().
src/asynchronous/transport/unix.rs Ensures accepted/connected Unix sockets capture raw_fd.
src/asynchronous/transport/tcp.rs Ensures accepted/connected TCP sockets capture raw_fd.
src/asynchronous/transport/vsock.rs Ensures accepted/connected vsock sockets capture raw_fd.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/hook_integration_unix.rs
Comment thread src/extension.rs Outdated
Comment thread src/extension.rs
Comment thread src/asynchronous/server.rs Outdated
Comment thread src/asynchronous/server.rs Outdated
Comment thread src/asynchronous/client.rs
@jiangliu
jiangliu force-pushed the gerry/secure-extension branch 2 times, most recently from e1496d5 to 8ebbffb Compare July 20, 2026 07:41
@jiangliu
jiangliu requested a review from Copilot July 20, 2026 07:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Comment thread src/asynchronous/client.rs
Comment thread src/asynchronous/client.rs
Comment thread src/asynchronous/client.rs
Comment thread src/extension.rs
Comment thread src/asynchronous/server.rs
Comment thread src/asynchronous/server.rs
@jiangliu
jiangliu force-pushed the gerry/secure-extension branch 2 times, most recently from ce6ccfd to cf31d6b Compare July 20, 2026 08:29
@jiangliu
jiangliu requested a review from Copilot July 20, 2026 08:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

src/asynchronous/server.rs:692

  • respond() applies transform_outbound, but it doesn't enforce MESSAGE_LENGTH_MAX on the transformed payload. A transform that expands data can cause oversized responses to be sent (especially for error/status responses that go through respond_with_status). Add check_oversize after the transform.
        let payload = self
            .conn_ctx
            .transform_outbound(payload)
            .map_err(|e| Error::Others(format!("transform_outbound failed: {}", e)))?;
        let msg = GenMessage::new_response(stream_id, payload);

Comment thread src/extension.rs Outdated
Comment thread src/asynchronous/server.rs
Comment thread src/asynchronous/server.rs
Comment thread src/asynchronous/client.rs
Comment thread src/asynchronous/stream.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/asynchronous/server.rs:694

  • respond() applies transform_outbound but never enforces MESSAGE_LENGTH_MAX. Other outbound paths (e.g. the normal unary response path) check size after transform; without a check here, an oversized (or expansion-prone) transform can cause the peer to reject/discard the message and the caller to hang until timeout.
        let payload = self
            .conn_ctx
            .transform_outbound(payload)
            .map_err(|e| Error::Others(format!("transform_outbound failed: {}", e)))?;
        let msg = GenMessage::new_response(stream_id, payload);

Comment thread src/extension.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/asynchronous/server.rs:694

  • respond() applies transform_outbound but does not enforce MESSAGE_LENGTH_MAX after the transform. A transform (e.g., encryption) can expand the payload beyond the limit, leading to oversized frames being written and then rejected by the peer during read/oversize checks.
        let payload = self
            .conn_ctx
            .transform_outbound(payload)
            .map_err(|e| Error::Others(format!("transform_outbound failed: {}", e)))?;
        let msg = GenMessage::new_response(stream_id, payload);

Comment thread src/asynchronous/server.rs Outdated
Comment thread src/asynchronous/client.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/asynchronous/server.rs:695

  • respond() applies transform_outbound but does not re-check the transformed payload size. A transform can expand data, so this can violate MESSAGE_LENGTH_MAX and produce frames the peer will reject or that can break framing.
        let payload = self
            .conn_ctx
            .transform_outbound(payload)
            .map_err(|e| Error::Others(format!("transform_outbound failed: {}", e)))?;
        let msg = GenMessage::new_response(stream_id, payload);

src/asynchronous/server.rs:455

  • When transform_outbound fails, this path tries to send an INTERNAL status via respond_with_status(), which will call transform_outbound again and likely fail the same way—leaving the client waiting until timeout. Consider closing the stream/connection to unblock the client when the transform is unusable.
                                        self.respond_with_status(
                                            stream_id,
                                            get_status(Code::INTERNAL, format!("transform_outbound: {}", e)),
                                        )
                                        .await;

Comment thread src/asynchronous/client.rs
Comment thread src/extension.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/asynchronous/server.rs:695

  • respond() applies transform_outbound, but it does not enforce MESSAGE_LENGTH_MAX (or call GenMessage::check()) after the transform. A transform can expand payload size (e.g., AEAD tag, framing, compression expansion), which would cause the peer to reject/discard the message and potentially hang waiting for a response. Add a post-transform size check before sending.
        let payload = self
            .conn_ctx
            .transform_outbound(payload)
            .map_err(|e| Error::Others(format!("transform_outbound failed: {}", e)))?;
        let msg = GenMessage::new_response(stream_id, payload);

Comment thread src/asynchronous/server.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (1)

src/proto.rs:197

  • Making GenMessage #[non_exhaustive] and adding a pub(crate) field is a breaking API change for any downstream crate that previously constructed/destructured ttrpc::proto::GenMessage. If the crate intends to preserve semver compatibility, consider keeping GenMessage out of the public API surface (e.g., pub(crate)/#[doc(hidden)]) or making the new field publicly settable with a documented default, and call out the breaking change explicitly in release notes.
/// This struct is `#[non_exhaustive]` to allow adding internal fields without
/// breaking downstream construction. Use the constructor methods
/// ([`new_data`](Self::new_data), [`new_data_decrypted`](Self::new_data_decrypted),
/// [`new_response`](Self::new_response), [`new_close`](Self::new_close)) instead of
/// struct literals.

Comment thread src/extension.rs
Comment thread src/extension.rs
Comment thread src/extension.rs
Comment thread src/extension.rs
Comment thread src/asynchronous/stream.rs
Comment thread src/asynchronous/stream.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/asynchronous/server.rs:702

  • respond() applies transform_outbound, but it never re-validates the transformed payload size. If a transform expands the payload beyond MESSAGE_LENGTH_MAX, the server will attempt to send an oversized frame (and the peer will reject it). Add an explicit size check (or msg.check()?) after the transform, consistent with the other response path.
            .conn_ctx
            .transform_outbound(payload)
            .map_err(|e| Error::Others(format!("transform_outbound failed: {}", e)))?;
        let msg = GenMessage::new_response(stream_id, payload);
        self.tx

Comment thread tests/hook_integration_unix.rs Outdated
Comment thread tests/hook_integration_unix.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

src/proto.rs:197

  • The doc comment for GenMessage is internally inconsistent: adding #[non_exhaustive] and a pub(crate) field does break downstream struct-literal construction, and the recommended constructor methods are pub(crate) + #[cfg(feature = "async")], so downstream users can’t follow this guidance (and sync builds won’t have them). Consider updating the docs to reflect that GenMessage is not intended to be constructed downstream (or make the constructors public and available without the async cfg).
/// Generic message of ttrpc.
///
/// This struct is `#[non_exhaustive]` to allow adding internal fields without
/// breaking downstream construction. Use the constructor methods
/// ([`new_data`](Self::new_data), [`new_data_decrypted`](Self::new_data_decrypted),
/// [`new_response`](Self::new_response), [`new_close`](Self::new_close)) instead of
/// struct literals.

src/asynchronous/server.rs:702

  • HandlerContext::respond() applies transform_outbound() but never re-checks the post-transform payload size. A transform that expands data (e.g. decompression or header-wrapping) can exceed MESSAGE_LENGTH_MAX and lead to the peer rejecting/discarding the message. The main response path performs this check, but error/status responses sent via respond() currently do not.
    async fn respond(&self, stream_id: u32, resp: Response) -> Result<()> {
        let payload = resp
            .encode()
            .map_err(err_to_others_err!(e, "Encode Response failed."))?;
        let payload = self
            .conn_ctx
            .transform_outbound(payload)
            .map_err(|e| Error::Others(format!("transform_outbound failed: {}", e)))?;
        let msg = GenMessage::new_response(stream_id, payload);
        self.tx

Comment thread tests/hook_integration_unix.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/asynchronous/server.rs:429

  • check_oversize(resp.compute_size()) runs before transform_outbound(). If a PayloadTransform shrinks payloads (e.g., compression), this will incorrectly reject responses that would be under the limit on the wire after transformation. Size enforcement should be based on the bytes actually sent (post-transform).
                    Some(mut resp) => {
                        // Server: check size before sending to client
                        if let Err(e) = check_oversize(resp.compute_size() as usize, true) {
                            resp = e.into();
                        }

src/asynchronous/utils.rs:264

  • This adds a new public field to TtrpcContext. Since TtrpcContext is a public struct, adding a field is a Rust semver-breaking change for any downstream code that constructs it with a struct literal—contradicting the PR description's “no breakage” claim. Consider documenting this as a breaking change and/or providing a stable construction/access pattern (e.g., constructors/getters) to reduce future churn.
    /// Opaque per-connection data from [`AcceptHook`](crate::extension::AcceptHook). Immutable after accept.
    /// See [`ConnectionData`](crate::extension::ConnectionData) for full contract.
    pub connection_data: Arc<ConnectionData>,
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (6)

src/extension.rs:383

  • transform_inbound() matches on self.payload_transform by value, which attempts to move the Option<Arc<..>> out of &self and will not compile. Match on a borrow (e.g., .as_ref() or &self.payload_transform) instead.
        match self.payload_transform {
            Some(ref xform) => xform.transform_inbound(payload),
            None => Ok(payload),
        }

src/extension.rs:392

  • transform_outbound() matches on self.payload_transform by value, which attempts to move the Option<Arc<..>> out of &self and will not compile. Match on a borrow (e.g., .as_ref() or &self.payload_transform) instead.
        match self.payload_transform {
            Some(ref xform) => xform.transform_outbound(payload),
            None => Ok(payload),
        }

src/extension.rs:402

  • transform_inbound_msg() uses if let Some(ref xform) = self.payload_transform, which tries to move payload_transform out of &self and will not compile. Borrow the option (e.g., if let Some(xform) = self.payload_transform.as_ref()).
        if let Some(ref xform) = self.payload_transform {
            msg.payload = xform.transform_inbound(std::mem::take(&mut msg.payload))?;
            // Update header length to match decrypted payload
            msg.header.length = msg.payload.len() as u32;
        }

src/extension.rs:413

  • transform_outbound_msg() uses if let Some(ref xform) = self.payload_transform, which tries to move payload_transform out of &self and will not compile. Borrow the option (e.g., if let Some(xform) = self.payload_transform.as_ref()).
        if let Some(ref xform) = self.payload_transform {
            msg.payload = xform.transform_outbound(std::mem::take(&mut msg.payload))?;
            // Update header length to match transformed payload
            msg.header.length = msg.payload.len() as u32;
        }

src/asynchronous/stream.rs:474

  • StreamSender::transform_outbound() matches on self.payload_transform by value, which attempts to move the Option<Arc<..>> out of &self and will not compile. Match on a borrow (e.g., .as_ref()) instead.
        match self.payload_transform {
            Some(ref xform) => xform
                .transform_outbound(buf)
                .map_err(|e| Error::Others(format!("transform_outbound failed: {}", e))),
            None => Ok(buf),

src/asynchronous/stream.rs:520

  • StreamReceiver::transform_inbound() matches on self.payload_transform by value, which attempts to move the Option<Arc<..>> out of &self and will not compile. Match on a borrow (e.g., .as_ref()) instead.
        match self.payload_transform {
            Some(ref xform) => xform
                .transform_inbound(payload)
                .map_err(|e| Error::Others(format!("transform_inbound failed: {}", e))),
            None => Ok(payload),

Comment thread src/proto.rs
@jiangliu
jiangliu force-pushed the gerry/secure-extension branch 2 times, most recently from 26858c4 to 5529d4e Compare July 22, 2026 02:11
@jiangliu
jiangliu requested a review from Copilot July 22, 2026 02:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/proto.rs:196

  • The doc comment says #[non_exhaustive] allows adding internal fields "without breaking downstream compatibility", but #[non_exhaustive] itself is a (source) breaking change for downstream code that does exhaustive matches or struct literals. The comment should be reworded to avoid promising compatibility that isn’t guaranteed.
/// This struct is `#[non_exhaustive]` to allow adding internal fields
/// (such as `internal_flags`) without breaking downstream compatibility.
/// `GenMessage` is constructed internally by the ttrpc framework and is
/// not intended to be built directly by downstream code.

Comment thread tests/hook_integration_unix.rs Outdated
Comment thread tests/hook_integration_unix.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread src/extension.rs
Comment thread src/extension.rs Outdated
jiangliu added 2 commits July 22, 2026 11:18
…r hooks

ttrpc currently provides no mechanism for applications to inject
connection-level policies such as encryption, authentication, or
file-descriptor passthrough.  Each RPC message passes through
serialization and transport layers with no opportunity for the
application to inspect or transform the wire-format bytes, leaving
security-critical concerns to be handled out-of-band or not at all.

Introduce a symmetric extension framework that exposes 10 injection
points across the client and server lifecycle:

 1. AcceptHook::on_accept  — server-side connection acceptance
 2. Unary REQUEST transform_inbound  — decrypt before dispatch
 3. Streaming DATA route-only        — no transform in router
 4. Unary RESPONSE transform_outbound — encrypt after handler
 5. Streaming DATA send transform_outbound — encrypt each chunk
 6. Streaming DATA recv transform_inbound  — decrypt each chunk
 7. ConnectHook::on_connect — client-side connection establishment
 8. Client REQUEST transform_outbound — encrypt before send
 9. Client RESPONSE transform_inbound — decrypt after recv
10. GenMessage internal_flags         — skip double-transform

The framework is symmetric: a server that registers hooks must pair
with a client that registers the matching hooks, and both sides share
the same PayloadTransform trait for consistent wire-format transforms.

New types in src/extension.rs:
  - PayloadTransform — trait for wire-format byte transforms
  - AcceptHook / ConnectHook — trait for connection lifecycle hooks
  - ConnectionContext — holds hook references, immutable after accept

Wire-format changes:
  - GenMessage gains a pub(crate) internal_flags field (never serialized)
    with a skip_transform bit to prevent double-decryption when the
    streaming_client=false path creates a faked DATA message from an
    already-decrypted REQUEST payload.
  - Helper constructors (new_data, new_data_decrypted, new_response,
    new_close) encapsulate internal_flags to keep call sites clean.

Error handling:
  - ConnectHook::on_connect errors are propagated through
    Client::with_hook, which now returns Result<Client>.  This prevents
    a rejected or failed hook from silently downgrading the connection
    to an untransformed, default context.
  - AcceptHook::on_accept errors are propagated through
    ConnectionContext::on_accept as HookError so callers can log the
    specific rejection reason before dropping the connection.
  - Server error/status responses sent via respond() / respond_with_status()
    now apply the connection's transform_outbound, so error paths on
    transformed connections still produce decryptable responses.

Platform support:
  - AcceptHook, ConnectHook, and all RawFd-dependent APIs are gated
    with #[cfg(unix)] and compile out cleanly on Windows.
  - Client::with_hook and the ConnectHook import are gated so the
    async client builds on Windows without the Unix-only hook.
  - Server base_ctx construction uses functional struct update syntax
    so the optional accept_hook field is initialized conditionally
    without a mutability warning on non-Unix targets.

Compatibility (source-level breaking changes):
  - TtrpcContext gains a `connection_data` field.  This breaks
    downstream code that constructs TtrpcContext with a struct literal.
    TtrpcContext now derives Default, so downstream code can use
    `..Default::default()` for forward-compatible construction.
  - GenMessage is marked #[non_exhaustive] and gains a pub(crate)
    internal_flags field, breaking downstream struct-literal
    construction.  GenMessage is intended for internal framework use;
    downstream code should not construct it directly.
  - Client::with_hook now returns Result<Client> instead of Client,
    breaking downstream code that constructs clients with connect hooks.
  - Size validation for unary responses is now performed after
    transform_outbound (post-transform), not before.  A pre-transform
    check would incorrectly reject responses that fit on the wire after
    a compression transform.

No changes to the wire-protocol header format; the transform operates
on the payload bytes after MessageHeader framing.

Signed-off-by: Jiang Liu <gerry@linux.alibaba.com>
The extension framework introduces 10 injection points for wire-format
transforms across the client-server lifecycle.  Without dedicated tests,
regressions in transform ordering (e.g., double-transform on the
streaming_client=false path) or missing injection points would silently
produce incorrect payload bytes.

Add comprehensive test coverage for all 10 injection points:

Unit tests (src/extension.rs, 20 tests):
- PayloadTransform::transform_inbound / transform_outbound with XOR-0xA5A5
- transform_inbound_msg / transform_outbound_msg on GenMessage
- AcceptHook::on_accept with mock TCP socket
- ConnectHook::on_connect with mock TCP connection
- ConnectionContext helpers (transform_*_msg delegation)
- File descriptor passthrough via ConnectionContext
- Asymmetric transform (distinct inbound/outbound keys)
- Empty payload edge case

Integration tests (tests/hook_integration_unix.rs, 19 tests):
- Symmetric client/server hooks with data and transform
- XOR transform on the wire (end-to-end encryption)
- Streaming with XOR transform (chunked encrypt/decrypt)
- streaming_client=false path (server creates faked DATA)
- streaming_server=false path (rejects DATA, unary only)
- Multiple concurrent streams on one connection
- Multiple concurrent connections with transform
- Unary request timeout
- Server-initiated stream close
- Server shutdown during active stream
- Connect hook called and receives valid raw fd
- Connect hook rejection fails connection
- Accept hook called on connection
- Accept hook rejects connection
- Connection data propagated to handler
- No-hook plaintext passthrough (baseline)

Also adds a unit test in src/asynchronous/client.rs verifying that
Client::with_hook fails when the Socket has no raw fd, preventing a
silent downgrade to an untransformed connection.

Signed-off-by: Jiang Liu <gerry@linux.alibaba.com>
@jiangliu
jiangliu force-pushed the gerry/secure-extension branch from 2d55cee to d318cda Compare July 22, 2026 03:23
@jiangliu
jiangliu requested a review from Copilot July 22, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Comment thread src/proto.rs
Comment on lines 191 to 206
/// Generic message of ttrpc.
///
/// This struct is `#[non_exhaustive]` to allow adding internal fields
/// (such as `internal_flags`) without further breaking changes.
/// `GenMessage` is constructed internally by the ttrpc framework and is
/// not intended to be built directly by downstream code.
#[non_exhaustive]
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct GenMessage {
pub header: MessageHeader,
pub payload: Vec<u8>,
/// Internal flags, never serialized to the wire.
/// Bit 0: skip_transform — payload is already decrypted, do not apply
/// `transform_inbound` in `StreamReceiver::recv()`.
pub(crate) internal_flags: u8,
}
Comment on lines +219 to +220
// Sleep longer than any reasonable client timeout
sleep(Duration::from_secs(10)).await;
Comment on lines +292 to +293
server.start().await.unwrap();
sleep(Duration::from_millis(100)).await;
Comment on lines +424 to +463
Some(resp) => {
// Encode response to protobuf, then apply transform.
//
// Note: size validation is performed *after* transform_outbound
// (below), not before. A transform may compress the payload,
// so checking the pre-transform protobuf size would incorrectly
// reject responses that fit on the wire after compression.
match resp.encode() {
Ok(payload) => {
// ── Injection Point 4/10: unary response transform_outbound ──
match self.conn_ctx.transform_outbound(payload) {
Ok(payload) => {
if check_oversize(payload.len(), true).is_err() {
self.respond_with_status(
stream_id,
get_status(
Code::INVALID_ARGUMENT,
"transformed response exceeds maximum message size",
),
)
.await;
return;
}
let msg = GenMessage::new_response(stream_id, payload);
self.tx
.send(SendingMessage::new(msg))
.await
.map_err(|e| error!("Send packet error {:?}", e))
.ok();
}
Err(e) => {
error!("transform_outbound failed: {}", e);
self.respond_with_status(
stream_id,
get_status(Code::INTERNAL, format!("transform_outbound: {}", e)),
)
.await;
}
}
}
Comment on lines +431 to +446
match resp.encode() {
Ok(payload) => {
// ── Injection Point 4/10: unary response transform_outbound ──
match self.conn_ctx.transform_outbound(payload) {
Ok(payload) => {
if check_oversize(payload.len(), true).is_err() {
self.respond_with_status(
stream_id,
get_status(
Code::INVALID_ARGUMENT,
"transformed response exceeds maximum message size",
),
)
.await;
return;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants