- `bind_tcp_first` + `TcpListenerHalves::accept_pair`: the real-socket BindFn source (port 0 resolved via local_addr — the `bound` reply field's data) and the per-accept (peer, halves) pair (the peer rides accept()'s return — the forwarded op's `peer` carrier) - unix bind-first parity shipped (OQ-TN-14): `bind_unix` + `UnixListenerHalves` (local_addr / queue-feeding accept_loop / bind-first accept_pair; an unnamed peer fails loud — the forwarded `peer` param requires an address) + `bind_unix_first` - assembly conveniences: `local_bind_first()` (tcp/unix dispatch; udp fails loud — no accept on a UDP socket, the shape is not defined there, never a fake listener) + `local_bindfirst_accept_loop()` (downcasts the opaque listener; a foreign type fails loud) - direct-op DialFn reuse verified + pinned: `local_dial` shared unchanged (the UDP framed wrap composes inside the closure); `direct_udp_through_local_dial_round_trips_framed` drives a REAL udp echo through the direct op's establisher path - tests/local_bindfirst.rs: tcp e2e over real sockets (bound fidelity — the test dials the reported address; two clients → two forwarded channels pumping independently, real distinct peers), unix parity (real path in `bound`, named-peer client via socket2, real peer path on the forwarded open), udp refusal, downcast-miss probe - accept-first shapes (accept_loop + AcceptQueue) untouched Verified: cargo test (default) + --all-features green (45 lib; 151 integration), clippy -D warnings clean (native all-features + wasm32), fmt clean, wasm32 check passes (default crate stays wasm-clean), cargo doc clean (no new warnings). Tasks: tunnels/local-bind-halves (status completed; Notes + Summary filled). CHANGELOG: Unreleased → Added.
403 lines
16 KiB
Rust
403 lines
16 KiB
Rust
//! Tests for the `local` feature's BIND-FIRST half (shape 3, ADR-008):
|
|
//! the real-socket `BindFn` sources (`bind_tcp_first` /
|
|
//! `bind_unix_first` — the `bound` reply field's data, port 0
|
|
//! resolved), the accept-pair loops (peer + halves per accept), and
|
|
//! the assembly convenience closures (`local_bind_first` +
|
|
//! `local_bindfirst_accept_loop`), through the real bind-first
|
|
//! establisher path over REAL sockets.
|
|
//!
|
|
//! Covered (the task's test plan): the bound address is the REAL
|
|
//! OS-chosen one (a fake `bound` would strand the test's dials), two
|
|
//! clients dial → two accepts → two forwarded channels pumping
|
|
//! independently (peer fidelity: each forwarded open carries the REAL
|
|
//! accepted peer), unix bind-first parity (OQ-TN-14 — the bind path is
|
|
//! the same shape as the dial path), and the udp bind-first refusal
|
|
//! (a UDP socket has no accept).
|
|
|
|
#![cfg(feature = "local")]
|
|
|
|
mod harness;
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use alkcall::channels::operations::{ChannelCore, OpenHandler};
|
|
use alkcall::core::auth::AuthContext;
|
|
use alkcall::protocol::wire::CallError;
|
|
use alktunnels::params::{parse_listen_reply, Substrate, SubstrateAddr, TunnelForwardedParams};
|
|
use alktunnels::producer::ResourceRegistry;
|
|
use serde_json::Value;
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
fn init_tracing() {
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_env_filter(EnvFilter::from_default_env())
|
|
.try_init();
|
|
}
|
|
|
|
/// The forwarded-op recording stub's peer log: the `peer` param of
|
|
/// every forwarded open the producer's dispatcher made (the
|
|
/// reply#2-fidelity probe surface — ADR-008 §3).
|
|
type PeerLog = Arc<tokio::sync::Mutex<Vec<SubstrateAddr>>>;
|
|
|
|
/// The forwarded-op opener-side serving stub (the queue-fed shape):
|
|
/// each forwarded open pops the NEXT app-side pair (the "local BIND
|
|
/// client" halves the test registered) and pumps the forwarded
|
|
/// channel against it, recording the open's `peer` param (the
|
|
/// reply#2-fidelity probe surface — ADR-008 §3). One listener session
|
|
/// = one token = MANY forwarded opens (per accept), so the
|
|
/// correlation is a queue, not a one-shot map.
|
|
fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenHandler {
|
|
Arc::new(
|
|
move |input: Value, _plan, conn: alkcall::core::types::Connection, _auth| {
|
|
let app_sides = Arc::clone(&app_sides);
|
|
let peers = Arc::clone(&peers);
|
|
tokio::spawn(async move {
|
|
let forwarded: TunnelForwardedParams = match serde_json::from_value(input) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "recording stub: params parse failed");
|
|
return;
|
|
}
|
|
};
|
|
peers.lock().await.push(forwarded.peer.clone());
|
|
let target = app_sides.lock().await.pop_front();
|
|
let Some(target) = target else {
|
|
tracing::error!(
|
|
listen = forwarded.listen,
|
|
"recording stub: no app-side pair registered"
|
|
);
|
|
return;
|
|
};
|
|
let Ok(bidi) = conn.accept_bi().await else {
|
|
tracing::error!("recording stub: accept_bi failed");
|
|
return;
|
|
};
|
|
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
|
|
})
|
|
},
|
|
)
|
|
}
|
|
|
|
/// The app-side pairs the stub pops per forwarded open (the "local
|
|
/// BIND client" halves — the stub's pump target; the far end is the
|
|
/// test's echo task).
|
|
type AppSideQueue =
|
|
Arc<tokio::sync::Mutex<std::collections::VecDeque<alktunnels::producer::TargetHandle>>>;
|
|
|
|
/// Install the recording stub on the opener (consumer) side (post-hoc
|
|
/// on the captured serving registry — W2).
|
|
async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, PeerLog) {
|
|
let app_sides: AppSideQueue = Arc::new(tokio::sync::Mutex::new(Default::default()));
|
|
let peers: PeerLog = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
|
let core = ChannelCore::new(
|
|
topo.consumer_manager.clone(),
|
|
alkcall::channels::policy::default_policy(),
|
|
);
|
|
core.register_openable(
|
|
alktunnels::params::tunnel_forwarded_spec(),
|
|
recording_forwarded_handler(Arc::clone(&app_sides), Arc::clone(&peers)),
|
|
&topo.consumer_registry,
|
|
AuthContext::anonymous(b"alk/tunnel"),
|
|
)
|
|
.expect("register recording forwarded stub on the opener");
|
|
(app_sides, peers)
|
|
}
|
|
|
|
/// Call the producer's open op on channel 0 with the FULL reply (the
|
|
/// bind-first reply projection probe — `bound` + `listen`).
|
|
async fn open_listen_with_reply(
|
|
topo: &harness::Topology,
|
|
resource: &str,
|
|
substrate: Substrate,
|
|
) -> Result<(u32, Value), CallError> {
|
|
let substrate_str = match substrate {
|
|
Substrate::Tcp => "tcp",
|
|
Substrate::Udp => "udp",
|
|
Substrate::Unix => "unix",
|
|
};
|
|
let payload = serde_json::json!({
|
|
"operationId": "channels/tunnel/sub",
|
|
"input": {
|
|
"resource": resource,
|
|
"substrate": substrate_str,
|
|
},
|
|
});
|
|
let response = topo.consumer_call.call_with_payload(payload).await;
|
|
let out = response.result?;
|
|
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
|
Ok((channel_id, out))
|
|
}
|
|
|
|
/// One app-side session: a raw duplex pair whose FAR end echoes (the
|
|
/// app-side session shape) — the stub pumps the forwarded channel
|
|
/// against the near end (pushed into the stub's queue); the test
|
|
/// drives the REAL accepted socket at the tunnel's other side, whose
|
|
/// bytes echo through the app-side session.
|
|
fn echo_app_side() -> alktunnels::producer::TargetHandle {
|
|
let (a, b) = tokio::io::duplex(64 * 1024);
|
|
let (a_read, a_write) = tokio::io::split(a);
|
|
let (b_read, b_write) = tokio::io::split(b);
|
|
tokio::spawn(async move {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
let (mut r, mut w) = (b_read, b_write);
|
|
let mut buf = vec![0u8; 8192];
|
|
loop {
|
|
match r.read(&mut buf).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(n) => {
|
|
if w.write_all(&buf[..n]).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
alktunnels::producer::TargetHandle {
|
|
read: Box::new(a_read),
|
|
write: Box::new(a_write),
|
|
}
|
|
}
|
|
|
|
/// THE bind-first flow over REAL TCP sockets (the `local` shape-3
|
|
/// gate): `bind_tcp_first` binds port 0 → the reply's `bound` is the
|
|
/// REAL OS-chosen address (the test dials it — a fake bound would
|
|
/// strand the dial) → two clients dial → two accepts → two forwarded
|
|
/// channels → both pump independently, and each forwarded open
|
|
/// carries the REAL accepted peer (peer fidelity, reply#2 class).
|
|
#[tokio::test]
|
|
async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() {
|
|
init_tracing();
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("bind", Substrate::Tcp, "127.0.0.1:0")
|
|
.await;
|
|
let topo = harness::wire_bindfirst(
|
|
registry,
|
|
alktunnels::local::local_bind_first(),
|
|
alktunnels::local::local_bindfirst_accept_loop(),
|
|
)
|
|
.await;
|
|
|
|
let (channel_id, reply) = open_listen_with_reply(&topo, "bind", Substrate::Tcp)
|
|
.await
|
|
.expect("listen open");
|
|
let projected = parse_listen_reply(&reply).expect("projected reply");
|
|
let bind_addr = match &projected.bound {
|
|
Some(SubstrateAddr::HostPort { host, port }) => {
|
|
assert_eq!(host, "127.0.0.1", "the bound host is the bound loopback");
|
|
assert_ne!(*port, 0, "port 0 was resolved to the OS-chosen port");
|
|
format!("{host}:{port}")
|
|
}
|
|
other => panic!("bind-first open carries the real bound address: {other:?}"),
|
|
};
|
|
let token = projected.listen.expect("the `listen` token");
|
|
assert_ne!(token, 0, "token 0 is never minted (the wire-illegal value)");
|
|
|
|
let (app_sides, peers) = install_recording_stub(&topo).await;
|
|
|
|
// The opener adopts the LISTEN channel (the idle side). The
|
|
// halves are HELD — dropping them EOFs the idle stream and tears
|
|
// the listener down (the ADR-008 sentinel is live even here).
|
|
let (_idle_send, _idle_recv) = topo
|
|
.consumer_manager
|
|
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
|
.await
|
|
.expect("adopt listen channel");
|
|
|
|
// Two app-side sessions (the stub pops one per forwarded open).
|
|
app_sides.lock().await.push_back(echo_app_side());
|
|
app_sides.lock().await.push_back(echo_app_side());
|
|
|
|
// Two clients dial the reported bound address (the reply#1
|
|
// fidelity pin: `bound` is the REAL address, not a placeholder).
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
let mut client1 = tokio::net::TcpStream::connect(&bind_addr)
|
|
.await
|
|
.expect("client 1 dials the bound address");
|
|
let mut client2 = tokio::net::TcpStream::connect(&bind_addr)
|
|
.await
|
|
.expect("client 2 dials the bound address");
|
|
|
|
// Both pump independently: interleaved writes, per-socket echoes.
|
|
client1.write_all(b"client-one").await.expect("write 1");
|
|
client2.write_all(b"client-two").await.expect("write 2");
|
|
let mut buf1 = vec![0u8; 10];
|
|
let mut buf2 = vec![0u8; 10];
|
|
let (r1, r2) = tokio::join!(
|
|
tokio::time::timeout(Duration::from_secs(5), client1.read_exact(&mut buf1)),
|
|
tokio::time::timeout(Duration::from_secs(5), client2.read_exact(&mut buf2)),
|
|
);
|
|
r1.expect("round trip 1").expect("read 1");
|
|
r2.expect("round trip 2").expect("read 2");
|
|
assert_eq!(&buf1, b"client-one", "pump 1 serves ITS socket");
|
|
assert_eq!(&buf2, b"client-two", "pump 2 serves ITS socket");
|
|
|
|
// Peer fidelity: each forwarded open carried the REAL accepted
|
|
// peer (loopback + a real ephemeral port; distinct per socket).
|
|
let logged = peers.lock().await.clone();
|
|
assert_eq!(logged.len(), 2, "one forwarded open per accept");
|
|
let mut ports = std::collections::HashSet::new();
|
|
for peer in &logged {
|
|
match peer {
|
|
SubstrateAddr::HostPort { host, port } => {
|
|
assert_eq!(host, "127.0.0.1", "the REAL peer host");
|
|
assert_ne!(*port, 0, "the REAL peer port");
|
|
ports.insert(*port);
|
|
}
|
|
other => panic!("tcp peer is HostPort-shaped: {other:?}"),
|
|
}
|
|
}
|
|
assert_eq!(ports.len(), 2, "two accepts carried distinct real peers");
|
|
}
|
|
|
|
/// Unix bind-first parity (OQ-TN-14 — the bind path ships with the
|
|
/// dial path): `bind_unix_first` binds a temp-path listener → the
|
|
/// reply's `bound` is the REAL bound socket path → a unix client
|
|
/// (bound to its own path, so the peer is representable) connects →
|
|
/// one accept → one forwarded channel carrying the REAL peer path →
|
|
/// byte round-trip.
|
|
#[tokio::test]
|
|
async fn bindfirst_unix_real_sockets_bound_and_pumped() {
|
|
init_tracing();
|
|
let dir = std::env::temp_dir().join(format!("alktunnels-bindfirst-{}", std::process::id()));
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let listen_path = dir.join("listen.sock");
|
|
let client_path = dir.join("client.sock");
|
|
let _ = std::fs::remove_file(&listen_path);
|
|
let _ = std::fs::remove_file(&client_path);
|
|
let listen_str = listen_path.to_string_lossy().into_owned();
|
|
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("ubind", Substrate::Unix, &listen_str)
|
|
.await;
|
|
let topo = harness::wire_bindfirst(
|
|
registry,
|
|
alktunnels::local::local_bind_first(),
|
|
alktunnels::local::local_bindfirst_accept_loop(),
|
|
)
|
|
.await;
|
|
|
|
let (channel_id, reply) = open_listen_with_reply(&topo, "ubind", Substrate::Unix)
|
|
.await
|
|
.expect("listen open");
|
|
let projected = parse_listen_reply(&reply).expect("projected reply");
|
|
assert_eq!(
|
|
projected.bound,
|
|
Some(SubstrateAddr::Path {
|
|
path: listen_str.clone()
|
|
}),
|
|
"the `bound` field is the REAL bound socket path"
|
|
);
|
|
let token = projected.listen.expect("the `listen` token");
|
|
assert_ne!(token, 0, "token 0 is never minted (the wire-illegal value)");
|
|
|
|
let (app_sides, peers) = install_recording_stub(&topo).await;
|
|
|
|
// The opener adopts the LISTEN channel (the idle side) — held for
|
|
// the test's lifetime (dropping the halves EOFs the idle stream
|
|
// and tears the listener down, the ADR-008 sentinel).
|
|
let (_idle_send, _idle_recv) = topo
|
|
.consumer_manager
|
|
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
|
.await
|
|
.expect("adopt listen channel");
|
|
|
|
// One app-side session (the stub pops it for the forwarded open).
|
|
app_sides.lock().await.push_back(echo_app_side());
|
|
|
|
// The unix client binds its own socket at a named path FIRST (an
|
|
// unnamed peer cannot be represented in the forwarded `peer`
|
|
// param — ADR-008 §3; the named-peer shape is the parity case).
|
|
// socket2: std has no `UnixStream::bind`; a socket2 socket binds
|
|
// the sockaddr at the path, then connects (the peer address is
|
|
// the client's own bound path).
|
|
let client_sock = socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)
|
|
.expect("socket2 client");
|
|
client_sock
|
|
.bind(&socket2::SockAddr::unix(&client_path).expect("client sockaddr"))
|
|
.expect("bind client path");
|
|
client_sock
|
|
.connect(&socket2::SockAddr::unix(&listen_path).expect("listen sockaddr"))
|
|
.expect("client dials");
|
|
let client_std: std::os::unix::net::UnixStream = client_sock.into();
|
|
client_std.set_nonblocking(true).expect("nonblocking");
|
|
let mut client = tokio::net::UnixStream::from_std(client_std).expect("tokio client");
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
client.write_all(b"unix-bind").await.expect("write");
|
|
let mut buf = vec![0u8; 9];
|
|
tokio::time::timeout(Duration::from_secs(5), client.read_exact(&mut buf))
|
|
.await
|
|
.expect("round trip")
|
|
.expect("read");
|
|
assert_eq!(&buf, b"unix-bind");
|
|
|
|
// Peer fidelity: the forwarded open carried the client's REAL
|
|
// bound path.
|
|
let logged = peers.lock().await.clone();
|
|
assert_eq!(
|
|
logged,
|
|
vec![SubstrateAddr::Path {
|
|
path: client_path.to_string_lossy().into_owned()
|
|
}],
|
|
"the forwarded open carried the REAL accepted peer path"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(&listen_path);
|
|
let _ = std::fs::remove_file(&client_path);
|
|
}
|
|
|
|
/// The udp bind-first refusal: a UDP socket has no accept, so the
|
|
/// bind closure fails loud (`handler_error`-class) — the shape is not
|
|
/// defined there, never a silent fake listener.
|
|
#[tokio::test]
|
|
async fn udp_bind_first_fails_loud() {
|
|
let registry = ResourceRegistry::new();
|
|
registry
|
|
.register("ubind", Substrate::Udp, "127.0.0.1:0")
|
|
.await;
|
|
let topo = harness::wire_bindfirst(
|
|
registry,
|
|
alktunnels::local::local_bind_first(),
|
|
alktunnels::local::local_bindfirst_accept_loop(),
|
|
)
|
|
.await;
|
|
|
|
let payload = serde_json::json!({
|
|
"operationId": "channels/tunnel/sub",
|
|
"input": {"resource": "ubind", "substrate": "udp"},
|
|
});
|
|
let response = topo.consumer_call.call_with_payload(payload).await;
|
|
let err = response.result.expect_err("udp bind-first must fail");
|
|
assert_eq!(err.code, "channel:open_failed");
|
|
let reason = err
|
|
.details
|
|
.as_ref()
|
|
.and_then(|d| d.get("reason"))
|
|
.and_then(|r| r.as_str())
|
|
.expect("reason in details");
|
|
assert_eq!(reason, "handler_error");
|
|
}
|
|
|
|
/// The accept-pair loop's mismatch probe: a listener NOT produced by
|
|
/// this module's bind closures fails loud inside the closure (the
|
|
/// downcast is the assembly side of the type-erased injection — a
|
|
/// foreign listener type is `handler_error`, never a phantom accept).
|
|
#[tokio::test]
|
|
async fn accept_pair_loop_downcast_miss_fails_loud() {
|
|
let loop_ = alktunnels::local::local_bindfirst_accept_loop();
|
|
let foreign: Arc<dyn std::any::Any + Send + Sync> = Arc::new(4242u32);
|
|
let err = loop_(foreign)
|
|
.await
|
|
.expect_err("a foreign listener fails loud");
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
alktunnels::producer::TunnelEstablishError::HandlerError(_)
|
|
),
|
|
"the downcast miss is handler_error-class: {err:?}"
|
|
);
|
|
}
|