Files
AuraVPN/crates/aura-proto/src/frame.rs
T
xah30 6c14c0d103 feat(proto,cli): v3.1 multi-hop scaffold — control kinds + config sections
Foundation for v3.1 onion routing (client → entry-relay → exit-server).
The relay/circuit runtime is implemented in a follow-up commit; this
scaffold lands the wire-level control extensions and the config schema:

- aura-proto: ControlKind gains ExtendBridge (client→relay), CircuitReady
  (relay→client), CircuitFailed (relay→client, with utf-8 reason); helpers
  encode_extend_bridge / decode_extend_bridge (1-byte family + 4/16 addr
  bytes + u16 port). Integration test in tests/control_extend.rs covers
  IPv4/IPv6 roundtrip + full magic-envelope wrap.
- aura-cli config: [server.relay] {enabled, allow_extend_to} +
  [client.circuit] {enabled, hops} sections; relay_whitelist() helper
  parses IP:port literals. All new fields serde-default, back-compat.
- crl_push.rs touched only to leave the new ControlKinds passing through
  the existing magic-envelope dispatcher unchanged.

Workspace: 247 tests passed (+12), clippy/fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:54:12 +03:00

684 lines
25 KiB
Rust

//! Wire format: the 5-byte protocol header (§6.1) and the application [`Frame`] enum (§6.3).
//!
//! Every Aura protocol message on the wire is a 5-byte header followed by a payload:
//!
//! ```text
//! byte 0 : msg_type (u8)
//! bytes 1..4 : length (u24, big-endian) = payload length in bytes
//! byte 4 : version = 0x01
//! bytes 5.. : payload (length bytes)
//! ```
//!
//! [`Frame`] is the post-handshake application payload. Each `Frame` is serialized with
//! [`Frame::encode`], AEAD-sealed, and shipped inside a [`MsgType::Data`] record (see
//! [`crate::session`]).
use bytes::Bytes;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::ProtoError;
/// Length in bytes of the protocol frame header.
pub const HEADER_LEN: usize = 5;
/// Protocol version carried in byte 4 of every header.
pub const PROTOCOL_VERSION: u8 = 0x01;
/// Largest payload expressible by the u24 length field.
pub const MAX_PAYLOAD_LEN: usize = 0x00FF_FFFF;
/// Message types carried in byte 0 of the header (§6.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum MsgType {
/// Handshake message 1 (C->S): hybrid public key + client nonce.
ClientHello = 0x01,
/// Handshake message 2 (S->C): hybrid ciphertext + server nonce.
ServerHello = 0x02,
/// Handshake message 4 (C->S, encrypted): client cert + signature.
ClientAuth = 0x03,
/// Handshake message 3 (S->C, encrypted): server cert + signature.
ServerAuth = 0x04,
/// Handshake Finished (encrypted): HMAC over the handshake hash.
Finished = 0x05,
/// Application data (encrypted): an AEAD-sealed [`Frame`].
Data = 0x06,
/// Fatal alert / error notification.
Alert = 0xFF,
}
impl MsgType {
/// Map the on-wire byte to a [`MsgType`].
///
/// # Errors
/// Returns [`ProtoError::UnknownMsgType`] for an unrecognized byte.
pub fn from_u8(b: u8) -> Result<Self, ProtoError> {
Ok(match b {
0x01 => Self::ClientHello,
0x02 => Self::ServerHello,
0x03 => Self::ClientAuth,
0x04 => Self::ServerAuth,
0x05 => Self::Finished,
0x06 => Self::Data,
0xFF => Self::Alert,
other => return Err(ProtoError::UnknownMsgType(other)),
})
}
}
/// Build a 5-byte header for `msg_type` carrying a payload of `payload_len` bytes.
///
/// # Errors
/// Returns [`ProtoError::FrameTooLarge`] if `payload_len` does not fit in the u24 length field.
pub fn encode_header(
msg_type: MsgType,
payload_len: usize,
) -> Result<[u8; HEADER_LEN], ProtoError> {
if payload_len > MAX_PAYLOAD_LEN {
return Err(ProtoError::FrameTooLarge(payload_len));
}
let len = payload_len as u32;
Ok([
msg_type as u8,
((len >> 16) & 0xFF) as u8,
((len >> 8) & 0xFF) as u8,
(len & 0xFF) as u8,
PROTOCOL_VERSION,
])
}
/// Parse a 5-byte header into `(msg_type, payload_len)`.
///
/// # Errors
/// Returns [`ProtoError::UnknownMsgType`] for an unrecognized type byte or
/// [`ProtoError::BadVersion`] if byte 4 is not [`PROTOCOL_VERSION`].
pub fn decode_header(header: &[u8; HEADER_LEN]) -> Result<(MsgType, usize), ProtoError> {
let msg_type = MsgType::from_u8(header[0])?;
let version = header[4];
if version != PROTOCOL_VERSION {
return Err(ProtoError::BadVersion(version));
}
let len = ((header[1] as usize) << 16) | ((header[2] as usize) << 8) | (header[3] as usize);
Ok((msg_type, len))
}
/// Write one full frame (`header || payload`) to `writer`.
///
/// # Errors
/// Returns [`ProtoError::FrameTooLarge`] if the payload is too long, or [`ProtoError::Io`] on a
/// write failure.
pub async fn write_frame<W>(
writer: &mut W,
msg_type: MsgType,
payload: &[u8],
) -> Result<(), ProtoError>
where
W: AsyncWrite + Unpin,
{
let header = encode_header(msg_type, payload.len())?;
writer.write_all(&header).await?;
writer.write_all(payload).await?;
writer.flush().await?;
Ok(())
}
/// A frame read off the wire: its type, the raw header bytes (useful as AEAD AAD and for the
/// handshake transcript hash), and the payload.
#[derive(Clone, Debug)]
pub struct RawFrame {
/// The decoded message type.
pub msg_type: MsgType,
/// The 5 header bytes exactly as transmitted.
pub header: [u8; HEADER_LEN],
/// The payload bytes.
pub payload: Vec<u8>,
}
impl RawFrame {
/// The full serialized frame (`header || payload`) exactly as it appeared on the wire.
///
/// Used to feed the handshake transcript hash, which must hash the bytes as transmitted.
#[must_use]
pub fn wire_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER_LEN + self.payload.len());
out.extend_from_slice(&self.header);
out.extend_from_slice(&self.payload);
out
}
}
/// Read one full frame (`header || payload`) from `reader`.
///
/// # Errors
/// Returns [`ProtoError::Io`] on a read failure (including a truncated frame / EOF), or a header
/// decode error from [`decode_header`].
pub async fn read_frame<R>(reader: &mut R) -> Result<RawFrame, ProtoError>
where
R: AsyncRead + Unpin,
{
let mut header = [0u8; HEADER_LEN];
reader.read_exact(&mut header).await?;
let (msg_type, len) = decode_header(&header)?;
let mut payload = vec![0u8; len];
reader.read_exact(&mut payload).await?;
Ok(RawFrame {
msg_type,
header,
payload,
})
}
/// Frame type tags used in the application [`Frame`] encoding (§6.3).
mod frame_tag {
pub const DATA: u8 = 0x01;
pub const PING: u8 = 0x02;
pub const PONG: u8 = 0x03;
pub const CLOSE: u8 = 0x04;
}
/// Kinds of in-band control message carried inside a [`CONTROL_ENVELOPE_MAGIC`]-prefixed payload.
///
/// The wire byte is the discriminant. Unknown values decode as [`ControlKind::Unknown`] so peers
/// running older builds gracefully ignore future kinds without dropping the connection.
///
/// v2's CRL push reuses the existing post-handshake [`crate::PacketConnection::send_packet`] path
/// rather than introducing a new [`Frame`] variant: a real IPv4/IPv6 packet always starts with
/// `0x4X` / `0x6X`, so the 4-byte magic [`CONTROL_ENVELOPE_MAGIC`] (which starts with `0xAA`) can
/// be safely multiplexed alongside ordinary packets without changing the on-wire frame schema or
/// any transport-level `match Frame` that already exists.
///
/// v3.1 multi-hop / onion routing adds three kinds for circuit setup:
///
/// * [`ControlKind::ExtendBridge`] (`0x03`) — client → relay, asking the relay to splice this
/// connection to a downstream `exit_addr`. Payload is the [`encode_extend_bridge`] binary form.
/// * [`ControlKind::CircuitReady`] (`0x04`) — relay → client, the bridge is up; no payload.
/// * [`ControlKind::CircuitFailed`] (`0x05`) — relay → client, the bridge could not be set up;
/// payload is a UTF-8 reason string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlKind {
/// Server -> client: push the server's current CRL (signed payload).
CrlPush,
/// Client -> server: acknowledge a [`ControlKind::CrlPush`].
CrlAck,
/// Client -> relay: please open a bridge to the given `exit_addr` (v3.1 multi-hop).
ExtendBridge,
/// Relay -> client: the bridge is up; the next bytes from the client travel opaquely to the
/// exit (v3.1 multi-hop).
CircuitReady,
/// Relay -> client: the bridge could not be set up; payload is a UTF-8 reason string (v3.1
/// multi-hop).
CircuitFailed,
/// Any byte the receiver does not recognise. The connection keeps running.
Unknown(u8),
}
impl ControlKind {
/// Encode this control kind to its on-wire byte.
#[must_use]
pub fn to_u8(self) -> u8 {
match self {
ControlKind::CrlPush => 0x01,
ControlKind::CrlAck => 0x02,
ControlKind::ExtendBridge => 0x03,
ControlKind::CircuitReady => 0x04,
ControlKind::CircuitFailed => 0x05,
ControlKind::Unknown(b) => b,
}
}
/// Decode an on-wire byte into a [`ControlKind`]. Unknown bytes yield [`ControlKind::Unknown`].
#[must_use]
pub fn from_u8(b: u8) -> Self {
match b {
0x01 => ControlKind::CrlPush,
0x02 => ControlKind::CrlAck,
0x03 => ControlKind::ExtendBridge,
0x04 => ControlKind::CircuitReady,
0x05 => ControlKind::CircuitFailed,
other => ControlKind::Unknown(other),
}
}
}
/// Encode an `ExtendBridge` payload describing the target `exit_addr`.
///
/// Wire layout (big-endian where multi-byte):
///
/// ```text
/// family(u8 = 4|6) || addr_bytes(4 or 16) || port(u16)
/// ```
///
/// The result is the **payload** of a [`ControlKind::ExtendBridge`] control envelope; the caller
/// wraps it with [`encode_control_envelope`].
#[must_use]
pub fn encode_extend_bridge(addr: std::net::SocketAddr) -> Vec<u8> {
let port = addr.port();
match addr.ip() {
std::net::IpAddr::V4(v4) => {
let octets = v4.octets();
let mut out = Vec::with_capacity(1 + 4 + 2);
out.push(4);
out.extend_from_slice(&octets);
out.extend_from_slice(&port.to_be_bytes());
out
}
std::net::IpAddr::V6(v6) => {
let octets = v6.octets();
let mut out = Vec::with_capacity(1 + 16 + 2);
out.push(6);
out.extend_from_slice(&octets);
out.extend_from_slice(&port.to_be_bytes());
out
}
}
}
/// Decode an `ExtendBridge` payload back into a [`std::net::SocketAddr`].
///
/// See [`encode_extend_bridge`] for the wire layout. Returns a static error string on any
/// truncation, unknown family, or trailing garbage.
pub fn decode_extend_bridge(payload: &[u8]) -> Result<std::net::SocketAddr, &'static str> {
if payload.is_empty() {
return Err("ExtendBridge: empty payload");
}
match payload[0] {
4 => {
if payload.len() != 1 + 4 + 2 {
return Err("ExtendBridge: bad v4 payload length");
}
let octets: [u8; 4] = payload[1..5]
.try_into()
.expect("slice of length 4 converts to [u8; 4]");
let port = u16::from_be_bytes([payload[5], payload[6]]);
let ip = std::net::Ipv4Addr::from(octets);
Ok(std::net::SocketAddr::new(std::net::IpAddr::V4(ip), port))
}
6 => {
if payload.len() != 1 + 16 + 2 {
return Err("ExtendBridge: bad v6 payload length");
}
let octets: [u8; 16] = payload[1..17]
.try_into()
.expect("slice of length 16 converts to [u8; 16]");
let port = u16::from_be_bytes([payload[17], payload[18]]);
let ip = std::net::Ipv6Addr::from(octets);
Ok(std::net::SocketAddr::new(std::net::IpAddr::V6(ip), port))
}
_ => Err("ExtendBridge: unknown address family"),
}
}
/// Application-level frames carried inside encrypted [`MsgType::Data`] records (§6.3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Frame {
/// A stream data payload.
Data {
/// Logical stream identifier.
stream_id: u32,
/// Opaque application bytes.
payload: Bytes,
},
/// Liveness probe.
Ping {
/// Monotonic sequence number echoed back in the matching [`Frame::Pong`].
seq: u32,
},
/// Reply to a [`Frame::Ping`].
Pong {
/// Sequence number copied from the [`Frame::Ping`].
seq: u32,
},
/// Orderly shutdown of the logical connection.
Close {
/// Application-defined close code.
code: u8,
/// Human-readable reason (UTF-8).
reason: String,
},
}
impl Frame {
/// Serialize this frame to its compact byte encoding.
///
/// Layout (all multi-byte integers big-endian):
/// * `Data` : `0x01 || stream_id(u32) || payload`
/// * `Ping` : `0x02 || seq(u32)`
/// * `Pong` : `0x03 || seq(u32)`
/// * `Close` : `0x04 || code(u8) || reason_len(u32) || reason_utf8`
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
match self {
Frame::Data { stream_id, payload } => {
out.push(frame_tag::DATA);
out.extend_from_slice(&stream_id.to_be_bytes());
out.extend_from_slice(payload);
}
Frame::Ping { seq } => {
out.push(frame_tag::PING);
out.extend_from_slice(&seq.to_be_bytes());
}
Frame::Pong { seq } => {
out.push(frame_tag::PONG);
out.extend_from_slice(&seq.to_be_bytes());
}
Frame::Close { code, reason } => {
out.push(frame_tag::CLOSE);
out.push(*code);
let bytes = reason.as_bytes();
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
}
out
}
/// Parse a frame from its byte encoding (the inverse of [`Frame::encode`]).
///
/// # Errors
/// Returns [`ProtoError::MalformedFrame`] if the buffer is truncated, has an unknown tag, or
/// (for `Close`) does not contain valid UTF-8.
pub fn decode(buf: &[u8]) -> Result<Self, ProtoError> {
let (&tag, rest) = buf
.split_first()
.ok_or(ProtoError::MalformedFrame("empty frame"))?;
match tag {
frame_tag::DATA => {
let stream_id = read_u32(rest, "Data.stream_id")?;
let payload = Bytes::copy_from_slice(&rest[4..]);
Ok(Frame::Data { stream_id, payload })
}
frame_tag::PING => Ok(Frame::Ping {
seq: read_u32(rest, "Ping.seq")?,
}),
frame_tag::PONG => Ok(Frame::Pong {
seq: read_u32(rest, "Pong.seq")?,
}),
frame_tag::CLOSE => {
let code = *rest
.first()
.ok_or(ProtoError::MalformedFrame("Close: missing code"))?;
let reason_len = read_u32(&rest[1..], "Close.reason_len")? as usize;
let reason_bytes = rest
.get(5..5 + reason_len)
.ok_or(ProtoError::MalformedFrame("Close: truncated reason"))?;
let reason = String::from_utf8(reason_bytes.to_vec())
.map_err(|_| ProtoError::MalformedFrame("Close: reason not UTF-8"))?;
Ok(Frame::Close { code, reason })
}
_ => Err(ProtoError::MalformedFrame("unknown frame tag")),
}
}
}
/// Read a big-endian u32 from the start of `buf`, erroring if it is too short.
fn read_u32(buf: &[u8], what: &'static str) -> Result<u32, ProtoError> {
let bytes: [u8; 4] = buf
.get(..4)
.ok_or(ProtoError::MalformedFrame(what))?
.try_into()
.expect("slice of length 4 converts to [u8; 4]");
Ok(u32::from_be_bytes(bytes))
}
/// Magic prefix marking a v2 control-envelope multiplexed through [`PacketConnection::send_packet`].
///
/// An IPv4 packet's first byte is `0x4X` and an IPv6 packet's first byte is `0x6X`, so the four
/// magic bytes `[0xAA, 0xAA, 0xC0, 0x01]` can never collide with a real IP packet — the TUN layer
/// already rejects anything starting with a byte whose top nibble is not `4` or `6`.
///
/// Envelope layout:
///
/// ```text
/// CONTROL_ENVELOPE_MAGIC (4 bytes) || kind (u8) || u32_be(payload_len) || payload
/// ```
pub const CONTROL_ENVELOPE_MAGIC: [u8; 4] = [0xAA, 0xAA, 0xC0, 0x01];
/// Build a control envelope around `kind` + `payload`, suitable for
/// [`crate::PacketConnection::send_packet`].
///
/// Layout: `MAGIC(4) || kind(u8) || u32_be(payload_len) || payload`.
#[must_use]
pub fn encode_control_envelope(kind: ControlKind, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(CONTROL_ENVELOPE_MAGIC.len() + 1 + 4 + payload.len());
out.extend_from_slice(&CONTROL_ENVELOPE_MAGIC);
out.push(kind.to_u8());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
out
}
/// Try to decode a buffer as a control envelope.
///
/// Returns `None` if `buf` does not start with [`CONTROL_ENVELOPE_MAGIC`] (i.e. it is a normal IP
/// packet). Returns [`ProtoError::MalformedFrame`] if the buffer starts with the magic but is
/// truncated or its length field overflows the buffer.
pub fn decode_control_envelope(buf: &[u8]) -> Result<Option<(ControlKind, Vec<u8>)>, ProtoError> {
if buf.len() < CONTROL_ENVELOPE_MAGIC.len() || &buf[..4] != CONTROL_ENVELOPE_MAGIC.as_slice() {
return Ok(None);
}
let rest = &buf[CONTROL_ENVELOPE_MAGIC.len()..];
let kind_byte = *rest
.first()
.ok_or(ProtoError::MalformedFrame("control envelope: missing kind"))?;
let kind = ControlKind::from_u8(kind_byte);
let len_bytes: [u8; 4] = rest
.get(1..5)
.ok_or(ProtoError::MalformedFrame(
"control envelope: missing payload length",
))?
.try_into()
.expect("slice of length 4 converts to [u8; 4]");
let payload_len = u32::from_be_bytes(len_bytes) as usize;
let payload = rest
.get(5..5 + payload_len)
.ok_or(ProtoError::MalformedFrame(
"control envelope: truncated payload",
))?
.to_vec();
Ok(Some((kind, payload)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_roundtrip_all_types() {
for (ty, byte) in [
(MsgType::ClientHello, 0x01u8),
(MsgType::ServerHello, 0x02),
(MsgType::ClientAuth, 0x03),
(MsgType::ServerAuth, 0x04),
(MsgType::Finished, 0x05),
(MsgType::Data, 0x06),
(MsgType::Alert, 0xFF),
] {
let h = encode_header(ty, 0x0012_3456).unwrap();
assert_eq!(h[0], byte);
assert_eq!(h[4], PROTOCOL_VERSION);
let (got_ty, got_len) = decode_header(&h).unwrap();
assert_eq!(got_ty, ty);
assert_eq!(got_len, 0x0012_3456);
}
}
#[test]
fn header_rejects_oversize_and_bad_version() {
assert!(matches!(
encode_header(MsgType::Data, MAX_PAYLOAD_LEN + 1),
Err(ProtoError::FrameTooLarge(_))
));
let mut h = encode_header(MsgType::Data, 1).unwrap();
h[4] = 0x02;
assert!(matches!(
decode_header(&h),
Err(ProtoError::BadVersion(0x02))
));
h[0] = 0x77;
assert!(matches!(
decode_header(&h),
Err(ProtoError::UnknownMsgType(0x77))
));
}
#[test]
fn frame_roundtrip() {
let frames = vec![
Frame::Data {
stream_id: 0xDEAD_BEEF,
payload: Bytes::from_static(b"hello world"),
},
Frame::Data {
stream_id: 0,
payload: Bytes::new(),
},
Frame::Ping { seq: 42 },
Frame::Pong { seq: 0xFFFF_FFFF },
Frame::Close {
code: 7,
reason: "going away \u{1f44b}".to_string(),
},
Frame::Close {
code: 0,
reason: String::new(),
},
];
for f in frames {
let encoded = f.encode();
let decoded = Frame::decode(&encoded).unwrap();
assert_eq!(f, decoded);
}
}
#[test]
fn frame_decode_rejects_garbage() {
assert!(Frame::decode(&[]).is_err());
assert!(Frame::decode(&[0x99]).is_err());
assert!(Frame::decode(&[frame_tag::PING, 0x00]).is_err()); // truncated u32
assert!(Frame::decode(&[frame_tag::CLOSE]).is_err()); // missing code
}
#[test]
fn control_envelope_roundtrip() {
let env = encode_control_envelope(ControlKind::CrlPush, b"hello");
assert_eq!(&env[..4], &CONTROL_ENVELOPE_MAGIC);
let (kind, payload) = decode_control_envelope(&env).unwrap().unwrap();
assert_eq!(kind, ControlKind::CrlPush);
assert_eq!(payload, b"hello");
}
#[test]
fn control_envelope_skips_normal_ip_packets() {
// IPv4 packet: first byte's top nibble is 4. Never collides with magic.
let ipv4 = vec![0x45u8, 0x00, 0x00, 0x14];
assert!(decode_control_envelope(&ipv4).unwrap().is_none());
// IPv6 packet: first byte's top nibble is 6.
let ipv6 = vec![0x60u8, 0x00, 0x00, 0x00];
assert!(decode_control_envelope(&ipv6).unwrap().is_none());
// Random short bytes that do not match the magic.
let other = vec![0xAAu8, 0xAA, 0xC0, 0x02];
assert!(decode_control_envelope(&other).unwrap().is_none());
// Shorter than the magic.
assert!(decode_control_envelope(&[0xAA, 0xAA]).unwrap().is_none());
}
#[test]
fn control_envelope_rejects_truncated_payload() {
let mut env = encode_control_envelope(ControlKind::CrlPush, b"payload-bytes");
// Trim a few bytes from the end to truncate the payload claimed by the length field.
env.truncate(env.len() - 3);
assert!(decode_control_envelope(&env).is_err());
}
#[test]
fn control_envelope_unknown_kind_decodes_as_unknown() {
// Hand-craft an envelope with a future kind byte.
let mut env = Vec::new();
env.extend_from_slice(&CONTROL_ENVELOPE_MAGIC);
env.push(0x77); // unknown kind
env.extend_from_slice(&3u32.to_be_bytes());
env.extend_from_slice(b"abc");
let (kind, payload) = decode_control_envelope(&env).unwrap().unwrap();
assert_eq!(kind, ControlKind::Unknown(0x77));
assert_eq!(payload, b"abc");
}
/// v3.1 multi-hop: round-trip `ExtendBridge` payload over IPv4 + IPv6 addresses, including
/// boundary ports.
#[test]
fn extend_bridge_roundtrip_v4_and_v6() {
let cases: &[std::net::SocketAddr] = &[
"203.0.113.10:443".parse().unwrap(),
"127.0.0.1:0".parse().unwrap(),
"255.255.255.255:65535".parse().unwrap(),
"[::1]:443".parse().unwrap(),
"[2001:db8::1]:65000".parse().unwrap(),
"[::]:0".parse().unwrap(),
];
for addr in cases {
let payload = encode_extend_bridge(*addr);
let decoded = decode_extend_bridge(&payload).unwrap();
assert_eq!(*addr, decoded, "addr {addr} round-tripped");
}
}
/// Hand-check the on-wire layout for an IPv4 case: `0x04 || octets(4) || port_be(2)`.
#[test]
fn extend_bridge_v4_wire_layout() {
let addr: std::net::SocketAddr = "10.0.0.42:443".parse().unwrap();
let p = encode_extend_bridge(addr);
assert_eq!(p.len(), 1 + 4 + 2);
assert_eq!(p[0], 4);
assert_eq!(&p[1..5], &[10, 0, 0, 42]);
assert_eq!(&p[5..7], &443u16.to_be_bytes());
}
/// Hand-check the on-wire layout for an IPv6 case: `0x06 || octets(16) || port_be(2)`.
#[test]
fn extend_bridge_v6_wire_layout() {
let addr: std::net::SocketAddr = "[2001:db8::1]:443".parse().unwrap();
let p = encode_extend_bridge(addr);
assert_eq!(p.len(), 1 + 16 + 2);
assert_eq!(p[0], 6);
assert_eq!(&p[17..19], &443u16.to_be_bytes());
}
/// Malformed `ExtendBridge` payloads are rejected (empty / wrong family / bad length).
#[test]
fn extend_bridge_rejects_bad_inputs() {
assert!(decode_extend_bridge(&[]).is_err());
// Unknown family.
assert!(decode_extend_bridge(&[7u8, 0, 0, 0, 0, 0, 0]).is_err());
// v4 family but truncated.
assert!(decode_extend_bridge(&[4u8, 1, 2, 3]).is_err());
// v4 family but extra trailing byte (should be exactly 7 bytes).
assert!(decode_extend_bridge(&[4u8, 1, 2, 3, 4, 0, 0, 0]).is_err());
// v6 family but truncated.
let mut bad6 = vec![6u8];
bad6.extend_from_slice(&[0u8; 10]);
assert!(decode_extend_bridge(&bad6).is_err());
}
/// `ControlKind` byte mapping is stable for every v3.1 variant.
#[test]
fn control_kind_bytes_stable() {
assert_eq!(ControlKind::ExtendBridge.to_u8(), 0x03);
assert_eq!(ControlKind::CircuitReady.to_u8(), 0x04);
assert_eq!(ControlKind::CircuitFailed.to_u8(), 0x05);
assert_eq!(ControlKind::from_u8(0x03), ControlKind::ExtendBridge);
assert_eq!(ControlKind::from_u8(0x04), ControlKind::CircuitReady);
assert_eq!(ControlKind::from_u8(0x05), ControlKind::CircuitFailed);
}
/// A `CircuitFailed` envelope round-trips with a UTF-8 reason string.
#[test]
fn circuit_failed_envelope_roundtrip() {
let reason = "not in allow_extend_to";
let env = encode_control_envelope(ControlKind::CircuitFailed, reason.as_bytes());
let (kind, payload) = decode_control_envelope(&env).unwrap().unwrap();
assert_eq!(kind, ControlKind::CircuitFailed);
assert_eq!(std::str::from_utf8(&payload).unwrap(), reason);
}
}