alksocks 0.1.0

SOCKS5 (RFC 1928) proxy protocol over alkcall channels: targeted-forked fast-socks5 typestate core plus producer/consumer sessions. Producer/consumer protocol crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! The consumer-half integration tests — `Socks5ChannelSession`
//! (CONNECT) end to end through the harness's consumer-side topology
//! (the producer's real command-dispatch path over channels; the
//! alkcall 0.8.0 establishment surface consumed from the consumer's
//! own `ChannelClient`).
//!
//! Covered: connect → echo round-trip (payload bytes both
//! directions); the dial-witness target forms (IP-string parses to an
//! IP target, domain passes through unresolved — ADR-012); refused
//! dial → the RFC reply code surfaced as a typed session error; the
//! scope-less FORBIDDEN open → typed open error, no session; drop
//! teardown (no leaked channel entries, the connection stays
//! healthy); half-close semantics (the write-half shutdown EOFs the
//! producer's pump cleanly); the compile-asserted no-`Clone` posture.

mod harness;

use std::sync::Arc;

use tokio::io::{AsyncReadExt, AsyncWriteExt};

use alksocks::consumer::{Socks5ChannelSession, TakenInner};
use alksocks::egress::SocksDialError;
use alksocks::error::SocksSessionError;
use alksocks::rfc1928::consts;
use alksocks::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
use alksocks::rfc1928::SocksError as SocksErrorKind;

use harness::{
    echo_dial, refusing_dial, wire_consumer_side, wire_consumer_side_with, ConsumerSideTopology,
};

/// A `SocksOpenError`-carrying open failure (the FORBIDDEN probe's
/// expectation shape).
fn expect_forbidden(err: &SocksSessionError) -> bool {
    match err {
        SocksSessionError::Open(open) => match open.open_ref() {
            alkcall::channels::client::ChannelOpenError::CallFailed { error } => {
                error.code == "FORBIDDEN"
            }
            _ => false,
        },
        _ => false,
    }
}

/// A dial that always refuses (the refusal-riding-in-stream probe's
/// dial — the RFC reply code must surface through the session type,
/// not as an open failure).
fn refusing_session_dial() -> alksocks::egress::DialFn {
    refusing_dial(SocksDialError::Refused("target down".into()))
}

/// Both managers hold only channel 0 (the phantom-channel property,
/// asserted from both sides). The producer's entry is reaped by its
/// open-op wrapper when the pump handler exits — an await-able
/// teardown that races the consumer-side reap; this probe polls past
/// the race instead of asserting instantly.
async fn no_leaked_channels(topo: &ConsumerSideTopology) {
    let deadline = std::time::Duration::from_secs(5);
    let consumer_ids = topo.consumer.manager().channel_ids();
    assert!(
        consumer_ids.iter().all(|&id| id == 0),
        "consumer leaked channel entries: {consumer_ids:?}"
    );
    tokio::time::timeout(deadline, async {
        loop {
            let producer_ids = topo.producer_manager.channel_ids();
            if producer_ids.iter().all(|&id| id == 0) {
                return;
            }
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        }
    })
    .await
    .expect("the producer's handler-exit teardown reaps the entry");
}

/// The happy path: `connect` → the session is a raw byte pipe → echo
/// round-trip both directions; the dial seam saw the parsed target;
/// the establisher witnessed the opener identity (CF-006).
#[tokio::test]
async fn connect_echo_round_trip_and_dial_witness() {
    let topo = wire_consumer_side(echo_dial()).await;

    let mut session = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("echo.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("the session connects");

    // The `split()`-returned halves are the last use of the session;
    // the tail test's alias import lives here.
    session
        .split()
        .expect("post-reply pipe")
        .1
        .write_all(b"ping")
        .await
        .expect("write the probe");
    let (mut read, _) = session.split().expect("post-reply pipe");
    let mut buf = [0u8; 4];
    tokio::time::timeout(
        std::time::Duration::from_secs(10),
        read.read_exact(&mut buf),
    )
    .await
    .expect("echo timed out")
    .expect("read the echo");
    assert_eq!(&buf, b"ping");

    // The establisher witnessed the per-call opener identity.
    assert_eq!(
        topo.identity_witness.lock().await.as_deref(),
        Some("consumer")
    );

    session.close().await;
    no_leaked_channels(&topo).await;
}

/// Target construction (ADR-012): an IP-string form parses to an IP
/// target (the dial seam receives `TargetAddr::Ip`); the domain form
/// passes through unresolved (`TargetAddr::Domain`). Both ride the
/// same happy path.
#[tokio::test]
async fn target_construction_ip_strings_parse_domains_pass_through() {
    let targets = Arc::new(tokio::sync::Mutex::new(Vec::new()));
    let topo = wire_consumer_side(harness::witnessing_echo_dial(Arc::clone(&targets))).await;

    // The domain form: passes through unresolved (the dial's job).
    let session = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("echo.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("domain connect");
    session.close().await;

    // The IP-string form: parsed to an IP target by `ToTargetAddr`.
    let session = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("10.0.0.1", 8443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("ip-string connect");
    session.close().await;

    let dialed = targets.lock().await.clone();
    assert_eq!(
        dialed,
        vec![
            TargetAddr::Domain("echo.test".to_owned(), 443),
            TargetAddr::Ip(std::net::SocketAddr::from(([10, 0, 0, 1], 8443)))
        ],
        "the dial seam received the parsed target forms (ADR-012)"
    );
    no_leaked_channels(&topo).await;
}

/// A refused dial: the RFC reply code surfaces through the session
/// type (`SocksSessionError::Rfc` — the in-stream refusal, never an
/// open failure), and no phantom channel remains on either side.
#[tokio::test]
async fn refused_dial_surfaces_rfc_reply_code_no_phantom_channel() {
    let topo = wire_consumer_side(refusing_session_dial()).await;

    let err = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("down.test", 80u16).to_target_addr().unwrap(),
    )
    .await
    .expect_err("the dial refused — the reply code rides the session error");

    match &err {
        SocksSessionError::Rfc(SocksErrorKind::ReplyError(code)) => {
            assert_eq!(code.as_u8(), consts::SOCKS5_REPLY_CONNECTION_REFUSED);
        }
        other => panic!("expected an Rfc reply-code refusal, got {other:?}"),
    }

    // Teardown-at-birth: the adopted channel is reaped before the
    // error returns (no phantom channel, either side).
    no_leaked_channels(&topo).await;

    // The connection is healthy: a fresh connect through a working
    // dial succeeds immediately.
    let topo2 = wire_consumer_side(echo_dial()).await;
    let session = Socks5ChannelSession::connect(
        topo2.consumer.as_ref(),
        ("echo.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("fresh connect after the refusal");
    session.close().await;
}

/// A scope-less identity: the registry's FORBIDDEN open failure is
/// the typed `SocksOpenError` through `SocksSessionError::Open` —
/// no session, no phantom channel (the open gate is the boundary).
#[tokio::test]
async fn forbidden_open_is_a_typed_open_error_with_no_session() {
    let bare = alkcall::core::auth::Identity {
        id: "scopeless".to_string(),
        scopes: vec![],
        resources: Default::default(),
    };
    let topo = wire_consumer_side_with(echo_dial(), None, None, Some(bare)).await;

    let err = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("gated.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect_err("a scope-less identity fails closed");

    assert!(
        expect_forbidden(&err),
        "FORBIDDEN is the open error: {err:?}"
    );
    no_leaked_channels(&topo).await;
}

/// Drop teardown: dropping a live session mid-pipe reaps the adopted
/// channel entry (no leak) and the connection stays healthy — a
/// fresh connect succeeds immediately after.
#[tokio::test]
async fn drop_teardown_reaps_and_connection_stays_healthy() {
    let topo = wire_consumer_side(echo_dial()).await;

    let mut session = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("dropped.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("the session connects");
    session
        .split()
        .expect("pipe")
        .1
        .write_all(b"mid")
        .await
        .expect("write before the drop");
    let (mut read, _) = session.split().expect("pipe");
    let mut buf = [0u8; 3];
    read.read_exact(&mut buf).await.expect("echo before drop");
    assert_eq!(&buf, b"mid");

    // Drop mid-stream (the teardown-ownership posture).
    drop(session);
    no_leaked_channels(&topo).await;

    // The connection is healthy: a fresh session round-trips.
    let mut fresh = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("fresh.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("fresh connect after the drop");
    fresh
        .split()
        .expect("pipe")
        .1
        .write_all(b"again")
        .await
        .expect("write");
    let (mut read, _) = fresh.split().expect("pipe");
    let mut buf = [0u8; 5];
    tokio::time::timeout(
        std::time::Duration::from_secs(10),
        read.read_exact(&mut buf),
    )
    .await
    .expect("echo timed out")
    .expect("read the fresh echo");
    assert_eq!(&buf, b"again");
    fresh.close().await;
    no_leaked_channels(&topo).await;
}

/// Half-close semantics: shutting the write half EOFs the producer's
/// pump cleanly (the EOF sentinel), while the read half stays alive
/// until the target's data flows. The pump's completion is the
/// producer-side evidence (the handler tracks the data plane — R-02).
#[tokio::test]
async fn half_close_write_eof_ends_the_producer_pump() {
    let topo = wire_consumer_side(echo_dial()).await;

    let session = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("halfclose.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("the session connects");
    let channel_id = session.channel_id();
    assert!(channel_id >= 1, "a producer-allocated id");
    assert!(topo.consumer.manager().has_channel(channel_id));

    // Split into owned halves via `into_inner` (the advanced-use
    // accessor), then half-close the write half.
    let TakenInner {
        session: _owner,
        inner,
    } = session.into_inner().expect("the session is live");
    let (mut read, mut write) = tokio::io::split(inner);
    write.write_all(b"pre").await.expect("write");
    let mut buf = [0u8; 3];
    read.read_exact(&mut buf).await.expect("echo");
    assert_eq!(&buf, b"pre");
    write.shutdown().await.expect("half-close the pipe");

    // The producer's pump observes the EOF sentinel and ends (the
    // two-pump shape's shutdown-on-completion; the echo target's
    // pump exits with it). The session-side observable: the read
    // half drains to EOF.
    drop(write);
    let mut eof = [0u8; 1];
    tokio::time::timeout(std::time::Duration::from_secs(10), async {
        loop {
            match read.read(&mut eof).await {
                Ok(0) | Err(_) => break,
                Ok(_) => continue,
            }
        }
    })
    .await
    .expect("the read resolves after the half-close");
    drop(read);
    drop(_owner);
    no_leaked_channels(&topo).await;
}

/// `into_inner` yields the forked client type paired with the
/// teardown-owning session; the pair's `close` reaps the channel.
#[tokio::test]
async fn into_inner_pairs_the_pipe_with_the_teardown_owner() {
    let topo = wire_consumer_side(echo_dial()).await;

    let session = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("inner.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("the session connects");
    let channel_id = session.channel_id();
    let TakenInner { session, inner } = session.into_inner().expect("the session is live");
    assert!(topo.consumer.manager().has_channel(channel_id));

    let (mut read, write) = tokio::io::split(inner);
    let mut buf = [0u8; 1];
    // The pipe is idle; the close below reaps the entry.
    let _ = tokio::time::timeout(std::time::Duration::from_millis(50), read.read(&mut buf)).await;
    drop(write);
    drop(read);

    let reaped = session.close().await;
    assert!(reaped, "the adopted channel entry existed");
    no_leaked_channels(&topo).await;
}

/// The no-`Clone` posture: the type is `!Clone` because its fields
/// are (the `compile_fail` doctest pins the type-level posture).
#[test]
fn session_type_is_not_clone() {
    // A `Clone`-bound assertion on a non-`Clone` type cannot be a
    // runtime test; the doctest is the compile-assert. This probe
    // pins the Send shape the type promises.
    fn assert_send<T: Send>() {}
    assert_send::<Socks5ChannelSession>();
}

/// The read half stays alive across a half-close (the echo flows
/// after the write half's EOF was already seen by the producer): a
/// payload written before the shutdown round-trips, and the read
/// half observes the target's own EOF after it (the two-pump
/// half-close semantics, both directions).
#[tokio::test]
async fn both_directions_survive_across_halves() {
    let topo = wire_consumer_side(echo_dial()).await;

    let mut session = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("halves.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("the session connects");

    // Write half: two writes, flush each (the chunk stream carries
    // them in order).
    let (_, mut write) = session.split().expect("pipe");
    write.write_all(b"one").await.expect("write one");
    write.flush().await.expect("flush");
    write.write_all(b"two").await.expect("write two");
    write.flush().await.expect("flush");
    drop(write);

    // Read half: the echo arrives in order.
    let (mut read, _) = session.split().expect("pipe");
    let mut buf = [0u8; 6];
    tokio::time::timeout(
        std::time::Duration::from_secs(10),
        read.read_exact(&mut buf),
    )
    .await
    .expect("echo timed out")
    .expect("read the echo");
    assert_eq!(&buf, b"onetwo");

    session.close().await;
    no_leaked_channels(&topo).await;
}

/// The `split()` accessor's `Option` shape: `None` after the pipe
/// was taken (the spent-session posture; the borrow checker makes
/// the direct post-`into_inner` call the only reachable shape).
#[tokio::test]
async fn close_after_into_inner_reaps() {
    let topo = wire_consumer_side(echo_dial()).await;
    let session = Socks5ChannelSession::connect(
        topo.consumer.as_ref(),
        ("spent.test", 443u16).to_target_addr().unwrap(),
    )
    .await
    .expect("the session connects");
    let channel_id = session.channel_id();
    let TakenInner { session, .. } = session.into_inner().expect("the session is live");
    // The inner pipe was taken; `close` still reaps the entry.
    assert!(session.close().await, "the entry existed before the close");
    no_leaked_channels(&topo).await;
    assert!(channel_id >= 1);
}