assembly: the cross-cutting integration suite + docs sync (batch 2 closes)
- tests/assembly-integration.rs (7 tests, local + composed): the full -D story (vanilla cred-bearing client -> TCP door + UDP door -> producer w/ local providers -> real echo targets; CONNECT + BIND + ASSOCIATE live, one-command-per-connection, teardown + fresh sessions), the composed story (direct_egress + bindfirst_accept + real TCP dial; all three commands; the lazy-table dial-count witness), mixed assembly (channels-native + door clients on one registration; composed seams beside native sessions), the assembly teardown matrix (UDP-door idle bound; channel reaped; fresh association after), the producer door driving all three commands, and the -D domain-target pin (resolution at the producer's seam, ADR-012). - Fix the drift the suite surfaced: ComposedEgress::next_reply ended fresh (never-opened-table) associations at birth — the ended-relay Err now fires only once a tunnel existed and every relay ended; the fresh table parks (the readiness contract). Regression-pinned in tests/composed-egress.rs (fresh_association_parks_before_any_ tunnel_is_opened). - Harness: the composed-accept machinery single-sourced into tests/harness.rs (the composed suites' shapes + the five-gate composed identity); composed-accept.rs consumes the shared shapes. - Docs sync: README Current State (the assembly layer landed; both suites named), lib.rs crate docs (the assembly-layer paragraph), consumer.md (the TokenClient door-only contract + the one-command-per-connection posture reconciled). - Task assembly-integration-suite: completed; Notes + Summary filled (the local-on-wasm failure observed: mio's unsupported-wasm error). Verification: cargo test (375), cargo test --all-features (615), clippy --all-targets -D warnings (default + all-features), fmt --check, doc --no-deps (0 warnings), both wasm checks (default + composed), local-on-wasm fails as designed.
This commit is contained in:
1 parent
4e1673eefc
commit
1addedfdd0
9 files changed
+1921
-205
No files matched your search
+19
-10
@@ -1,7 +1,8 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-24 (the protocol core landed + the cross-cutting
|
||||
integration suite; the rfc1928 doc's targeted fork 2026-09-22; the
|
||||
last_updated: 2026-09-25 (the assembly layer landed + the cross-cutting
|
||||
assembly integration suite; the protocol core landed + the cross-cutting
|
||||
integration suite 2026-09-24; the rfc1928 doc's targeted fork 2026-09-22; the
|
||||
vendor extraction amendment ADR-011 §Amendment 2026-09-21; Phase 1
|
||||
initial draft 2026-09-20 — the OQ-SK ledger promoted into ADRs
|
||||
001-012 and open-questions.md)
|
||||
@@ -20,17 +21,17 @@ architecture spec docs and the ADRs.
|
||||
|
||||
## Current State
|
||||
|
||||
Phase 1 (Architecture) — the **protocol core is implemented**. Phase
|
||||
Phase 1 (Architecture) — the **protocol core and the assembly layer
|
||||
are implemented**. Phase
|
||||
0 (`docs/research/phase-0.md`) is converged (2026-09-14) and
|
||||
POC-complete (all numbered POCs passed or were dropped with the scope
|
||||
decision); the OQ-SK ledger's Phase 1 inputs are collected and
|
||||
promoted into ADRs 001–013. All docs are `draft` — they transition
|
||||
promoted into ADRs 001–014. All docs are `draft` — they transition
|
||||
to `reviewed` when their open questions resolve and the architecture
|
||||
review passes.
|
||||
|
||||
The landed core (the protocol-only batch; no `local`/`composed` code
|
||||
exists yet): the RFC 1928 core in `src/rfc1928/` (the targeted fork,
|
||||
ADR-013 — the vendor group's six tasks landed and passed the
|
||||
The landed surface: the RFC 1928 core in `src/rfc1928/` (the targeted
|
||||
fork, ADR-013 — the vendor group's six tasks landed and passed the
|
||||
extraction review), the ASSOCIATE datagram frame codec (`src/wire.rs`,
|
||||
ADR-003), the open-op params + typed error surface (`src/params.rs` /
|
||||
`src/error.rs`, ADR-001), the three seam types (`src/egress.rs`,
|
||||
@@ -38,9 +39,17 @@ ADR-004), the producer half (`src/producer.rs` — the session-gate
|
||||
establisher, the command-dispatch pump, the three commands, the scope
|
||||
gates), and the consumer half (`src/consumer.rs` — the
|
||||
connect/associate/bind session types with teardown ownership). The
|
||||
batch-1 cross-cutting integration suite (`tests/core-integration.rs`)
|
||||
pins the core's whole behavior end to end; the `local` and
|
||||
`composed` provider groups follow (batch 2).
|
||||
assembly layer (batch 2) landed with it: the `local` providers
|
||||
(`src/local/` — tcp_dial / udp_egress / bind_accept, the producer-side
|
||||
`ProducerDoor`, ADR-008/009), the front doors (`src/door.rs` — the
|
||||
NoAuth raw-pipe + RFC 1929 cred-bearing TCP door, the BIND door, the
|
||||
UDP door, ADR-005/003/014), and the composed providers
|
||||
(`src/composed/` — the direct-op egress + the bind-first accept over
|
||||
alktunnels 0.2.0, ADR-007/008). Both cross-cutting integration suites
|
||||
pin the behavior end to end: `tests/core-integration.rs` (the
|
||||
protocol core) and `tests/assembly-integration.rs` (the assembly
|
||||
layer — the full `-D` story through the doors, the composed variant
|
||||
set, mixed assembly, the assembly teardown matrix).
|
||||
|
||||
## Documents
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-24 (the BIND spent posture reconciled — the
|
||||
last_updated: 2026-09-25 (the cred-bearing door's call-connection
|
||||
contract reconciled — TokenClient's door-only posture, one command per
|
||||
accepted connection; the BIND spent posture reconciled — the
|
||||
take-guarded reply#2 reader, review 002 C-1/D-1; the landed session
|
||||
signatures reconciled 2026-09-24; Phase 1 initial draft 2026-09-20)
|
||||
---
|
||||
@@ -123,7 +125,17 @@ BIND door):
|
||||
crosses the channel as a SOCKS password; the channel identity
|
||||
never reaches the vanilla client. The door checks credentials
|
||||
itself (no forked `AuthMethod` impl needed — the door owns the
|
||||
accept loop).
|
||||
accept loop). The mapped identity rides the open-op call's
|
||||
`auth_token` payload (CF-005 precedence (1)) via
|
||||
`TokenClient::wrap` — the client's channel-0 `CallConnection` is
|
||||
taken once per door (the door-only contract: a cred-bearing
|
||||
door's client is a door client; other shapes — the UDP door's
|
||||
association, channels-native sessions — ride their own
|
||||
connections to the same producer registration, one registration,
|
||||
many connections). The vanilla conversation is
|
||||
**one command per accepted connection** (the door's RFC half
|
||||
consumes the greeting + one command; the data plane rides to
|
||||
EOF).
|
||||
|
||||
Door variant selection (NoAuth vs cred-bearing; the UDP door's bind
|
||||
address) is assembly-layer configuration.
|
||||
|
||||
+15
-2
@@ -194,6 +194,14 @@ pub struct ComposedEgress {
|
||||
/// The factory `Sender` alone does not close the queue (the
|
||||
/// egress holds it) — the count is the design.
|
||||
live_relays: usize,
|
||||
/// Whether any tunnel has ever been opened. The lazy table starts
|
||||
/// empty: a fresh association's `next_reply` parks until the first
|
||||
/// `send` opens a tunnel (the producer's stage selects the channel
|
||||
/// branch first on a quiescent association — the association
|
||||
/// serves until a datagram arrives or the channel EOFs). The
|
||||
/// ended-relay `Err` fires only once at least one tunnel existed
|
||||
/// and every relay has ended (the closed-queue condition).
|
||||
ever_opened: bool,
|
||||
}
|
||||
|
||||
/// The shared inbound queue's message: a tunnel's datagram (keyed by
|
||||
@@ -281,6 +289,7 @@ impl ComposedEgress {
|
||||
inbound: rx,
|
||||
inbound_tx: tx,
|
||||
live_relays: 0,
|
||||
ever_opened: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +315,7 @@ impl ComposedEgress {
|
||||
// bug). The relay task owns the bare read half.
|
||||
tokio::spawn(relay_read(taken.read, self.inbound_tx.clone(), key.clone()));
|
||||
self.live_relays += 1;
|
||||
self.ever_opened = true;
|
||||
self.entries.insert(
|
||||
key.clone(),
|
||||
TableEntry {
|
||||
@@ -368,11 +378,14 @@ impl Egress for ComposedEgress {
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
if self.live_relays == 0 {
|
||||
if self.ever_opened && self.live_relays == 0 {
|
||||
// Every relay ended (the tunnels ended): a closed
|
||||
// relay set must NOT spin the stage's select (an
|
||||
// instantly-ready None would starve the channel
|
||||
// branch) — end the association instead.
|
||||
// branch) — end the association instead. A fresh
|
||||
// association (no tunnel ever opened) parks here —
|
||||
// the channel branch governs until the first
|
||||
// datagram arrives.
|
||||
return Err(EgressError::General("all tunnel relays ended".into()));
|
||||
}
|
||||
match self.inbound.recv().await {
|
||||
|
||||
+20
-11
@@ -40,21 +40,30 @@
|
||||
//! sessions — [`Socks5ChannelSession`] (CONNECT),
|
||||
//! [`AssociateSession`] (the framed datagram stage),
|
||||
//! [`BindSession`] (the reply#1/reply#2 reader) — each owning its
|
||||
//! channel's teardown; no `Clone`.
|
||||
//! channel's teardown; no `Clone`. The cred-bearing front door's
|
||||
//! re-origination rides the token-carrying surface (`TokenClient`
|
||||
//! + the `*_with_token` constructor twins).
|
||||
//!
|
||||
//! `local` (the socket-backed assembly-layer providers —
|
||||
//! [`crate::local::tcp_dial`] / [`crate::local::udp_egress`] /
|
||||
//! [`crate::local::bind_accept`], ADR-008/009) is feature-gated: the
|
||||
//! default crate is protocol-only and wasm-clean by construction, and
|
||||
//! the composed providers ride `composed`
|
||||
//! ([`crate::composed::direct_egress`] — the alktunnels direct-op
|
||||
//! egress, ADR-008/009).
|
||||
//! The assembly layer (feature-gated, ADR-008/009): `local` (the
|
||||
//! socket-backed providers — `local::tcp_dial` / `local::udp_egress`
|
||||
//! / `local::bind_accept` — plus the producer-side
|
||||
//! `local::ProducerDoor`) and `door` (the consumer's front doors —
|
||||
//! the NoAuth raw-pipe + RFC 1929 cred-bearing `FrontDoor`, the
|
||||
//! `UdpFrontDoor` with the RFC codec at the door boundary only,
|
||||
//! ADR-005/003/014) ride `local`; the composed providers
|
||||
//! (`composed::direct_egress` — the alktunnels direct-op egress,
|
||||
//! `composed::bindfirst_accept` — the bind-first accept,
|
||||
//! ADR-007/008) ride `composed`. The default crate is protocol-only
|
||||
//! and wasm-clean by construction.
|
||||
//!
|
||||
//! Architecture: `docs/architecture/overview.md` (spec set:
|
||||
//! wire.md, producer.md, consumer.md, rfc1928.md); the cross-cutting
|
||||
//! behavior is pinned end to end by `tests/core-integration.rs`
|
||||
//! (one registration, all three commands; concurrency; the teardown
|
||||
//! matrix; the identity/scope matrix; the readiness contract).
|
||||
//! behavior is pinned end to end by `tests/core-integration.rs` (the
|
||||
//! protocol core: one registration, all three commands; concurrency;
|
||||
//! the teardown matrix; the identity/scope matrix; the readiness
|
||||
//! contract) and `tests/assembly-integration.rs` (the assembly
|
||||
//! layer: the full `-D` story through the doors, the composed
|
||||
//! variant set, mixed assembly, the assembly teardown matrix).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: implementation/assembly-integration-suite
|
||||
name: Assembly — the cross-cutting integration suite + docs sync
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [implementation/door-udp, implementation/local-backend-producer, implementation/composed-accept]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
@@ -61,17 +61,17 @@ Deliverables:
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The full `-D` story (vanilla client → door → session →
|
||||
- [x] The full `-D` story (vanilla client → door → session →
|
||||
producer → real target) pinned end to end for all three
|
||||
commands
|
||||
- [ ] The composed variant set pinned end to end (all three commands)
|
||||
- [ ] Mixed assembly (doors + channels-native + composed) concurrent
|
||||
- [x] The composed variant set pinned end to end (all three commands)
|
||||
- [x] Mixed assembly (doors + channels-native + composed) concurrent
|
||||
on one producer — pinned
|
||||
- [ ] The assembly teardown matrix pinned (door disconnects, idle
|
||||
- [x] The assembly teardown matrix pinned (door disconnects, idle
|
||||
bounds, fresh-session-after-teardown)
|
||||
- [ ] All four verification targets green (default, all-features,
|
||||
- [x] All four verification targets green (default, all-features,
|
||||
wasm default, wasm composed)
|
||||
- [ ] Docs synced (README Current State, lib.rs crate docs, any
|
||||
- [x] Docs synced (README Current State, lib.rs crate docs, any
|
||||
drift reconciled)
|
||||
|
||||
## References
|
||||
@@ -86,8 +86,118 @@ Deliverables:
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
> Filled by the implementation agent (2026-09-25).
|
||||
|
||||
- **The spec drift the implementation surfaced (one real drift,
|
||||
reconciled in `src/composed/mod.rs`):** `ComposedEgress::next_reply`
|
||||
returned the association-end `Err` ("all tunnel relays ended") on a
|
||||
*fresh* association too — the lazy table starts with `live_relays ==
|
||||
0`, so the producer's datagram stage (which parks on `next_reply`
|
||||
when no datagram has arrived) ended every quiescent association at
|
||||
birth. The composed-egress suite drove the egress send-first and
|
||||
never hit it; the assembly suite's `AssociateSession` (open → the
|
||||
stage parks → the first datagram) did. Fix: an `ever_opened` flag —
|
||||
the ended-relay `Err` fires only once at least one tunnel existed
|
||||
and every relay has ended; a never-opened table parks (the readiness
|
||||
contract's `Pending`, the channel branch governs). The landed spec
|
||||
wording (producer.md: "`Pending` when nothing is ready") already
|
||||
stated the correct contract — the implementation was the divergent
|
||||
side. Regression-pinned in `tests/composed-egress.rs`
|
||||
(`fresh_association_parks_before_any_tunnel_is_opened`).
|
||||
- **Two assembly-shape findings recorded in the tests (not code
|
||||
changes — the landed contracts are correct):**
|
||||
- The cred-bearing door's `TokenClient::wrap` takes the client's
|
||||
channel-0 `CallConnection` once (the door-only contract,
|
||||
door-tcp's Notes) — the UDP door's association (and any other
|
||||
consumption shape) rides its own channels connection to the same
|
||||
producer registration. The suite pins the assembly story as "one
|
||||
registration, many connections" (consumer.md reconciled).
|
||||
- The RFC conversation is **one command per connection** at every
|
||||
door shape (the cred-bearing TCP door's RFC half consumes the
|
||||
greeting + one command; the producer-side `ProducerDoor`'s
|
||||
`serve_socks5_over` likewise serves one command per accepted
|
||||
socket — a second command's bytes ride the first session's data
|
||||
plane). The vanilla one-command-per-connection shape is pinned by
|
||||
the suite's structure (each command opens its own door
|
||||
connection).
|
||||
- **Harness consolidation (review 002 N-5's discipline):** the
|
||||
composed-accept machinery the assembly suite also consumes (the
|
||||
test bind, the accept queue + loop, the channel-set probe, the
|
||||
identities) moved from `tests/composed-accept.rs` into
|
||||
`tests/harness.rs` (single-sourced; `composed-accept.rs` consumes
|
||||
the same shapes via the harness re-exports — its test bodies are
|
||||
unchanged in behavior). The five-gate composed identity
|
||||
(`composed_full_opener_identity` — `tunnel:open` + `tunnel:direct`
|
||||
+ `tunnel:forwarded` + `socks5:open` + `socks5:bind`) is new.
|
||||
- **The wasm gate, observed**: `cargo check --features local --target
|
||||
wasm32-unknown-unknown` fails as designed (the alktty posture) —
|
||||
the first error is `error: This wasm target is unsupported by mio.
|
||||
If using Tokio, disable the net feature.` (mio v1.2.3, via
|
||||
`tokio/net` which `local` enables), followed by the mio `sys`
|
||||
module's unresolved imports (`crate::sys::IoSourceState`,
|
||||
`crate::sys::tcp`, `Selector`, `event`). Both default and
|
||||
`--features composed` wasm checks are green.
|
||||
- **The `-D` story's door wiring**: the cred-bearing TCP door + the
|
||||
UDP door each take their own `ChannelClient` (two connections to
|
||||
the one registration); the tests' `DoorTokenProvider` resolves the
|
||||
mapped `door-`-prefixed identity ids (the same producer-side
|
||||
resolution policy shape the door-tcp suite pins). The producer-door
|
||||
test drives the producer's own front door (`ProducerDoor` →
|
||||
`serve_socks5_over` over the `local` provider set) through all
|
||||
three commands.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> Filled on completion (2026-09-25).
|
||||
|
||||
Landed the batch-2 closing task: `tests/assembly-integration.rs` —
|
||||
seven cross-cutting integration tests pinning the whole assembly layer
|
||||
end to end (feature `local` + `composed`):
|
||||
|
||||
1. **The full `-D` story** (`full_dash_d_story_...`): a vanilla
|
||||
tun2proxy-shaped client through the cred-bearing RFC 1929 TCP door
|
||||
+ the UDP door against one producer registration with the `local`
|
||||
providers and real echo targets — CONNECT + BIND (the app server
|
||||
dials the advertised address; reply#2 carries the real peer) +
|
||||
ASSOCIATE (the RFC-header'd datagram through the relay socket)
|
||||
live simultaneously, each exercised mid-life of the others, the
|
||||
door-client disconnect ending only its own sessions, fresh
|
||||
sessions succeeding after (no connection poisoning).
|
||||
2. **The composed story** (`composed_story_...`): the producer with
|
||||
`composed::direct_egress` + `composed::bindfirst_accept` + a real
|
||||
TCP dial — CONNECT, ASSOCIATE (the lazy table; the direct-op dial
|
||||
count witnesses the lazy open), BIND (the real `bound` + the real
|
||||
`peer` + the pass-through plane) — all three commands, all live.
|
||||
3. **Mixed assembly, twice**: channels-native sessions + door clients
|
||||
concurrent on one registration (the doors on their own
|
||||
connections), and composed seams beside channels-native sessions
|
||||
(the composed suite's producer; teardowns end only their own
|
||||
sessions; the channel-set probe after the settle).
|
||||
4. **The teardown matrix, assembly edition**: the UDP door's idle
|
||||
bound fires on the vanished door client, the association's channel
|
||||
reaps (the channel count witnesses), a fresh association succeeds
|
||||
immediately (no connection poisoning).
|
||||
5. **The producer's own front door** (`ProducerDoor`) drives all
|
||||
three commands through one door + one registration (the `-D`
|
||||
story's other half; the faithful-RFC BIND variant).
|
||||
6. **The `-D` domain-target pin**: a domain-form CONNECT through the
|
||||
NoAuth raw-pipe door resolves at the producer's seam (ADR-012).
|
||||
|
||||
The reconciled drift: `ComposedEgress`'s never-opened-table association
|
||||
end (fixed in `src/composed/mod.rs` — the `ever_opened` guard;
|
||||
regression-pinned in `tests/composed-egress.rs`). Docs synced:
|
||||
`README.md` Current State (the assembly layer landed; both suites
|
||||
named), `lib.rs` crate docs (the assembly-layer paragraph + both
|
||||
suites named), `consumer.md` §Door variants (the `TokenClient`
|
||||
door-only contract + one-command-per-connection posture reconciled —
|
||||
the drift the suite surfaced, recorded in Notes). Harness: the
|
||||
composed-accept machinery single-sourced into `tests/harness.rs` (the
|
||||
N-5 discipline).
|
||||
|
||||
Verification: `cargo test` (375 green across 12 targets), `cargo test
|
||||
--all-features` (615 green), `cargo clippy --all-targets -- -D
|
||||
warnings` + `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
clean, `cargo fmt --check` clean, `cargo doc --no-deps` clean (0
|
||||
warnings), both wasm checks green (default + `--features composed`,
|
||||
check + clippy), and the `local`-on-wasm build failure observed +
|
||||
recorded (the alktty posture — mio's unsupported-wasm error).
|
||||
File diff suppressed because it is too large.
Load diff
+30
-171
@@ -17,151 +17,43 @@
|
||||
mod harness;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alksocks::composed::{bindfirst_accept, register_forwarded_serving};
|
||||
use alksocks::consumer::BindSession;
|
||||
use alksocks::rfc1928::util::target_addr::ToTargetAddr;
|
||||
use alktunnels::params::{Substrate, SubstrateAddr};
|
||||
use alktunnels::producer::{
|
||||
BindFirstAcceptLoop, BindFn, BoundListener, ResourceRegistry, TargetHandle,
|
||||
TunnelEstablishError,
|
||||
};
|
||||
use alksocks::rfc1928::util::target_addr::{TargetAddr, ToTargetAddr};
|
||||
use alktunnels::params::Substrate;
|
||||
use alktunnels::producer::ResourceRegistry;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// The resource the bind-first producer serves (the tests' only
|
||||
/// subscription — the registry namespace gate).
|
||||
const RESOURCE: &str = "bind";
|
||||
|
||||
/// The bound address the test bind reports (the "OS-chosen" stand-in —
|
||||
/// the test's queue listener owns it; fidelity is the assert's job).
|
||||
const BOUND_PORT: u16 = 42113;
|
||||
|
||||
/// The peer address the accepted pairs report (the "app server" — the
|
||||
/// forwarded op's `peer` param carries it to reply#2).
|
||||
const PEER_PORT: u16 = 55231;
|
||||
|
||||
/// The test bind (the assembly's bind seam): reports the test's
|
||||
/// bound address + an opaque listener witness.
|
||||
fn test_bind() -> BindFn {
|
||||
Arc::new(|_substrate, _backing| {
|
||||
Box::pin(async {
|
||||
Ok(BoundListener {
|
||||
listener: Arc::new(()) as Arc<dyn std::any::Any + Send + Sync>,
|
||||
bound: SubstrateAddr::HostPort {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: BOUND_PORT,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The accept-queue pair the test accept loop pops from (the alktunnels
|
||||
/// listener test's shape: a VecDeque + Notify — one accepted pair per
|
||||
/// dispatcher call).
|
||||
type AcceptQueue = (
|
||||
Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
|
||||
Arc<tokio::sync::Notify>,
|
||||
);
|
||||
|
||||
fn fresh_queue() -> AcceptQueue {
|
||||
(
|
||||
Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new())),
|
||||
Arc::new(Notify::new()),
|
||||
)
|
||||
}
|
||||
|
||||
/// An accepted "connection" for the test accept loop: a duplex pair
|
||||
/// with an echo task on the far end (the app server's socket stand-in
|
||||
/// — the data plane's echo confirms the full round trip).
|
||||
fn accepted_halves(peer_addr: (&str, u16)) -> (SubstrateAddr, TargetHandle) {
|
||||
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
|
||||
let (mut t_read, mut t_write) = tokio::io::split(target_side);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match t_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => {
|
||||
if t_write.write_all(&buf[..n]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (c_read, c_write) = tokio::io::split(consumer_side);
|
||||
(
|
||||
SubstrateAddr::HostPort {
|
||||
host: peer_addr.0.to_string(),
|
||||
port: peer_addr.1,
|
||||
},
|
||||
TargetHandle {
|
||||
read: Box::new(c_read),
|
||||
write: Box::new(c_write),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The test accept loop (the dispatcher invokes the closure per
|
||||
/// accept): pop one accepted pair, or park until notified.
|
||||
fn queue_loop(queue: AcceptQueue) -> BindFirstAcceptLoop {
|
||||
let (queue, notify) = queue;
|
||||
Arc::new(move |_listener| {
|
||||
let queue = Arc::clone(&queue);
|
||||
let notify = Arc::clone(¬ify);
|
||||
Box::pin(async move {
|
||||
let popped = {
|
||||
let notified = std::pin::pin!(notify.notified());
|
||||
let mut q = queue.lock().await;
|
||||
if let Some(pair) = q.pop_front() {
|
||||
Some(pair)
|
||||
} else {
|
||||
drop(q);
|
||||
notified.await;
|
||||
let mut q = queue.lock().await;
|
||||
q.pop_front()
|
||||
}
|
||||
};
|
||||
popped.ok_or(TunnelEstablishError::DialFailed(
|
||||
"listener closed".to_string(),
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Push an accepted pair into the test's accept queue.
|
||||
async fn push_accept(queue: &AcceptQueue, peer: (&str, u16)) {
|
||||
let (peer_addr, handle) = accepted_halves(peer);
|
||||
queue.0.lock().await.push_back((peer_addr, handle));
|
||||
queue.1.notify_waiters();
|
||||
}
|
||||
use harness::{
|
||||
assert_composed_channels, composed_fresh_queue, composed_opener_identity, composed_push_accept,
|
||||
composed_queue_loop, composed_scopeless_opener_identity, composed_test_bind,
|
||||
wire_bindfirst_composed, COMPOSED_BOUND_PORT, COMPOSED_PEER_PORT, COMPOSED_RESOURCE,
|
||||
};
|
||||
|
||||
/// The full wiring: the bind-first topology with the composed accept
|
||||
/// factory + the forwarded serving registered post-hoc on the opener's
|
||||
/// registry. Returns everything the tests drive.
|
||||
struct Suite {
|
||||
topo: harness::BindFirstTopology,
|
||||
queue: AcceptQueue,
|
||||
queue: harness::ComposedAcceptQueue,
|
||||
}
|
||||
|
||||
async fn wire_suite() -> Suite {
|
||||
wire_suite_with_identity(harness::composed_opener_identity()).await
|
||||
wire_suite_with_identity(composed_opener_identity()).await
|
||||
}
|
||||
|
||||
async fn wire_suite_with_identity(identity: alkcall::core::auth::Identity) -> Suite {
|
||||
let resource_registry = ResourceRegistry::new();
|
||||
resource_registry
|
||||
.register(RESOURCE, Substrate::Tcp, "assembly-owned")
|
||||
.register(COMPOSED_RESOURCE, Substrate::Tcp, "assembly-owned")
|
||||
.await;
|
||||
let queue = fresh_queue();
|
||||
let topo = harness::wire_bindfirst_composed(
|
||||
let queue = composed_fresh_queue();
|
||||
let topo = wire_bindfirst_composed(
|
||||
resource_registry,
|
||||
test_bind(),
|
||||
queue_loop(queue.clone()),
|
||||
composed_test_bind(),
|
||||
composed_queue_loop(queue.clone()),
|
||||
None, // the SOCKS5 openable registers post-hoc, below — the composed factory needs the opener's client
|
||||
identity,
|
||||
)
|
||||
@@ -180,7 +72,7 @@ async fn wire_suite_with_identity(identity: alkcall::core::auth::Identity) -> Su
|
||||
// producer's registry — with the REAL composed factory, which
|
||||
// closes over the opener's client + the correlation registry (both
|
||||
// only exist now).
|
||||
let factory = bindfirst_accept(Arc::clone(&topo.opener), Arc::clone(®istry), RESOURCE);
|
||||
let factory = bindfirst_accept(topo.opener.clone(), registry, COMPOSED_RESOURCE);
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
topo.producer_manager.clone(),
|
||||
alkcall::channels::policy::default_policy(),
|
||||
@@ -212,33 +104,6 @@ async fn bind_session(suite: &Suite) -> BindSession {
|
||||
.expect("the session binds through the composed accept")
|
||||
}
|
||||
|
||||
/// The no-leak probe: the opener side ends with channel 0 (+ the
|
||||
/// live channels the test names); the producer side ends with channel
|
||||
/// 0, the SOCKS5 channels the test names, AND any odd id — the
|
||||
/// producer-side ADOPTED forwarded channels (opener-allocated ids,
|
||||
/// adopted inside the producer's `open_forwarded` caller; per-session
|
||||
/// ownership per alktunnels ADR-005 — their entries reap at transport
|
||||
/// EOF, not at the listener's teardown; upstream's own bind-first
|
||||
/// suites make the same concession in `no_stray_channels`).
|
||||
async fn assert_only_channels(
|
||||
topo: &harness::BindFirstTopology,
|
||||
opener_extra: &[u32],
|
||||
producer_extra: &[u32],
|
||||
) {
|
||||
let opener_ids = topo.opener.manager().channel_ids();
|
||||
let producer_ids = topo.producer_manager.channel_ids();
|
||||
let opener_ok = opener_ids
|
||||
.iter()
|
||||
.all(|id| *id == 0 || opener_extra.contains(id));
|
||||
let producer_ok = producer_ids
|
||||
.iter()
|
||||
.all(|id| *id == 0 || *id % 2 == 1 || producer_extra.contains(id));
|
||||
assert!(
|
||||
opener_ok && producer_ok,
|
||||
"channel sets: opener={opener_ids:?} producer={producer_ids:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// THE gate: the full composed BIND story — reply#1 carries the real
|
||||
/// `bound` (the graduation's vanilla fidelity), an app server "dials"
|
||||
/// (the queue accept), reply#2 carries the real peer (the forwarded
|
||||
@@ -268,10 +133,7 @@ async fn composed_bind_full_story_bound_peer_and_round_trip() {
|
||||
let bind_addr = session.reply1().expect("reply#1").bind_addr;
|
||||
assert_eq!(
|
||||
bind_addr,
|
||||
alksocks::rfc1928::util::target_addr::TargetAddr::Ip(SocketAddr::from((
|
||||
[127, 0, 0, 1],
|
||||
BOUND_PORT
|
||||
))),
|
||||
TargetAddr::Ip(SocketAddr::from(([127, 0, 0, 1], COMPOSED_BOUND_PORT))),
|
||||
"reply#1 advertises the real OS-chosen bound"
|
||||
);
|
||||
|
||||
@@ -279,7 +141,7 @@ async fn composed_bind_full_story_bound_peer_and_round_trip() {
|
||||
// producer's accept loop opens the forwarded channel toward the
|
||||
// opener; the correlation registry delivers it into the accept
|
||||
// future; reply#2 carries the real peer.
|
||||
push_accept(&suite.queue, ("10.0.0.1", PEER_PORT)).await;
|
||||
composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await;
|
||||
let mut plane = tokio::time::timeout(Duration::from_secs(10), session.await_inbound())
|
||||
.await
|
||||
.expect("reply#2 within timeout")
|
||||
@@ -287,10 +149,7 @@ async fn composed_bind_full_story_bound_peer_and_round_trip() {
|
||||
.expect("the plane exists");
|
||||
assert_eq!(
|
||||
plane.peer,
|
||||
alksocks::rfc1928::util::target_addr::TargetAddr::Ip(SocketAddr::from((
|
||||
[10, 0, 0, 1],
|
||||
PEER_PORT
|
||||
))),
|
||||
TargetAddr::Ip(SocketAddr::from(([10, 0, 0, 1], COMPOSED_PEER_PORT))),
|
||||
"reply#2 carries the forwarded op's real peer"
|
||||
);
|
||||
|
||||
@@ -324,10 +183,10 @@ async fn composed_bind_full_story_bound_peer_and_round_trip() {
|
||||
// The opener side holds only channel 0 (the producer-allocated
|
||||
// forwarded channel's entry rides the opener's manager per its own
|
||||
// adopter contract — alktunnels ADR-005). The producer side may
|
||||
// retain the adopted forwarded channel (see `assert_only_channels`
|
||||
// retain the adopted forwarded channel (see `assert_composed_channels`
|
||||
// — its adopter's session dropped with the plane; the entry reaps
|
||||
// at transport EOF, not at the listener's).
|
||||
assert_only_channels(&suite.topo, &[0], &[channel_id]).await;
|
||||
assert_composed_channels(&suite.topo, &[0], &[channel_id]).await;
|
||||
}
|
||||
|
||||
/// The client-gone teardown (the drop-witness): dropping the BIND
|
||||
@@ -382,7 +241,7 @@ async fn client_gone_teardown_releases_the_listener() {
|
||||
suite.queue.0.lock().await.is_empty(),
|
||||
"the dropped listener consumed nothing"
|
||||
);
|
||||
assert_only_channels(&suite.topo, &[0], &[0]).await;
|
||||
assert_composed_channels(&suite.topo, &[0], &[0]).await;
|
||||
}
|
||||
|
||||
/// Two concurrent BIND sessions get distinct tokens (the mint is
|
||||
@@ -401,8 +260,8 @@ async fn concurrent_bind_sessions_get_distinct_tokens() {
|
||||
// distinct tokens — observably: both sessions resolve their own
|
||||
// forwarded sessions, the correlation delivered each to the right
|
||||
// waiter).
|
||||
push_accept(&suite.queue, ("10.0.0.1", PEER_PORT)).await;
|
||||
push_accept(&suite.queue, ("10.0.0.2", PEER_PORT + 1)).await;
|
||||
composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await;
|
||||
composed_push_accept(&suite.queue, ("10.0.0.2", COMPOSED_PEER_PORT + 1)).await;
|
||||
|
||||
let mut p1 = tokio::time::timeout(Duration::from_secs(10), s1.await_inbound())
|
||||
.await
|
||||
@@ -442,7 +301,7 @@ async fn concurrent_bind_sessions_get_distinct_tokens() {
|
||||
assert!(s1.close().await);
|
||||
assert!(s2.close().await);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_only_channels(&suite.topo, &[0], &[0]).await;
|
||||
assert_composed_channels(&suite.topo, &[0], &[0]).await;
|
||||
}
|
||||
|
||||
/// A forwarded-open failure is per-accept isolated: an opener identity
|
||||
@@ -452,13 +311,13 @@ async fn concurrent_bind_sessions_get_distinct_tokens() {
|
||||
/// usable, and the failure does not tear the listener down.
|
||||
#[tokio::test]
|
||||
async fn forwarded_open_failure_is_per_accept_isolated() {
|
||||
let suite = wire_suite_with_identity(harness::composed_scopeless_opener_identity()).await;
|
||||
let suite = wire_suite_with_identity(composed_scopeless_opener_identity()).await;
|
||||
let mut session = bind_session(&suite).await;
|
||||
let _ = session.reply1().expect("reply#1");
|
||||
|
||||
// The accept fires (the queue accept consumed — the producer's
|
||||
// accept loop is alive), but the forwarded open fails FORBIDDEN.
|
||||
push_accept(&suite.queue, ("10.0.0.1", PEER_PORT)).await;
|
||||
composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await;
|
||||
|
||||
// The forwarded-open failure is per-accept isolated AT THE TUNNEL
|
||||
// LAYER: the producer's accept loop drops that accepted connection
|
||||
@@ -468,7 +327,7 @@ async fn forwarded_open_failure_is_per_accept_isolated() {
|
||||
// accepts and the listener survives (the alktunnels producer
|
||||
// suite's `forwarded_open_failure_isolates_the_accept` pin, end to
|
||||
// end).
|
||||
push_accept(&suite.queue, ("10.0.0.1", PEER_PORT)).await;
|
||||
composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
suite.queue.0.lock().await.is_empty(),
|
||||
@@ -493,10 +352,10 @@ async fn late_forwarded_open_after_client_gone_drops_the_channel() {
|
||||
// arriving in the race window may reach the dispatcher before the
|
||||
// EOF sentinel does; the forwarded open then finds a dead oneshot
|
||||
// (the correlation miss) and drops the channel.
|
||||
push_accept(&suite.queue, ("10.0.0.1", PEER_PORT)).await;
|
||||
composed_push_accept(&suite.queue, ("10.0.0.1", COMPOSED_PEER_PORT)).await;
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
// Either the listener was already gone (the accept queued
|
||||
// unconsumed) or the forwarded channel dropped at birth — either
|
||||
// way: no leaked channels on the opener side.
|
||||
assert_only_channels(&suite.topo, &[0], &[0]).await;
|
||||
assert_composed_channels(&suite.topo, &[0], &[0]).await;
|
||||
}
|
||||
@@ -161,6 +161,33 @@ async fn datagrams_round_trip_per_destination_multiple_tunnels() {
|
||||
.expect_err("next_reply stays Pending when nothing is ready");
|
||||
}
|
||||
|
||||
/// A fresh association (no tunnel ever opened) parks in `next_reply`
|
||||
/// — the stage's select is governed by the channel branch until the
|
||||
/// first datagram opens a tunnel (the assembly suite's drift finding:
|
||||
/// the ended-relay `Err` fired on the never-opened table too, killing
|
||||
/// every quiescent association at birth). Pinned on its own.
|
||||
#[tokio::test]
|
||||
async fn fresh_association_parks_before_any_tunnel_is_opened() {
|
||||
let dial_count = Arc::new(AtomicUsize::new(0));
|
||||
let topo = wire_direct(counting_udp_echo_dial(Arc::clone(&dial_count))).await;
|
||||
let mut egress = egress_for(&topo, 8).await;
|
||||
|
||||
// The never-opened table: `next_reply` parks (Pending), never an
|
||||
// instantly-ready `Err` (the stage would end the association
|
||||
// before its first datagram).
|
||||
{
|
||||
let mut poll = egress.next_reply();
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), &mut poll)
|
||||
.await
|
||||
.expect_err("the fresh association's next_reply parks");
|
||||
}
|
||||
|
||||
// After the first datagram opens a tunnel, the normal contract
|
||||
// holds (the round-trip works).
|
||||
round_trip(egress.as_mut(), &dest(53), b"after").await;
|
||||
assert_eq!(dial_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lru_eviction_reopens_evicted_destinations() {
|
||||
let dial_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -1211,6 +1211,182 @@ pub fn composed_scopeless_opener_identity() -> Identity {
|
||||
}
|
||||
}
|
||||
|
||||
/// The composed suite's full identity: the five gates the composed
|
||||
/// three-command story crosses (`tunnel:open` + `tunnel:direct` +
|
||||
/// `tunnel:forwarded` + `socks5:open` + `socks5:bind`) — the assembly
|
||||
/// suite's producer registration serves the direct op (CONNECT +
|
||||
/// ASSOCIATE's tunnels) beside the bind-first op (BIND's listener).
|
||||
pub fn composed_full_opener_identity() -> Identity {
|
||||
Identity {
|
||||
id: "composed-opener-full".to_string(),
|
||||
scopes: vec![
|
||||
alktunnels::TUNNEL_OPEN_SCOPE.to_string(),
|
||||
alktunnels::TUNNEL_DIRECT_SCOPE.to_string(),
|
||||
alktunnels::TUNNEL_FORWARDED_SCOPE.to_string(),
|
||||
SOCKS5_OPEN_SCOPE.to_string(),
|
||||
SOCKS5_BIND_SCOPE.to_string(),
|
||||
],
|
||||
resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- The composed accept's shared machinery (review 002 N-5) ----------------
|
||||
//
|
||||
// The bind/accept-queue stand-ins and the channel-set probe the
|
||||
// composed suites consume — single-sourced here now that two suites
|
||||
// (composed-accept + assembly-integration) drive the same shapes.
|
||||
|
||||
/// The resource the bind-first producer serves (the composed suites'
|
||||
/// only subscription — the registry namespace gate).
|
||||
pub const COMPOSED_RESOURCE: &str = "bind";
|
||||
|
||||
/// The bound address the test bind reports (the "OS-chosen" stand-in —
|
||||
/// the test's queue listener owns it; fidelity is the assert's job).
|
||||
pub const COMPOSED_BOUND_PORT: u16 = 42113;
|
||||
|
||||
/// The peer address the accepted pairs report (the "app server" — the
|
||||
/// forwarded op's `peer` param carries it to reply#2).
|
||||
pub const COMPOSED_PEER_PORT: u16 = 55231;
|
||||
|
||||
/// The test bind (the assembly's bind seam): reports the test's
|
||||
/// bound address + an opaque listener witness.
|
||||
pub fn composed_test_bind() -> alktunnels::producer::BindFn {
|
||||
Arc::new(|_substrate, _backing| {
|
||||
Box::pin(async {
|
||||
Ok(alktunnels::producer::BoundListener {
|
||||
listener: Arc::new(()) as Arc<dyn std::any::Any + Send + Sync>,
|
||||
bound: alktunnels::params::SubstrateAddr::HostPort {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: COMPOSED_BOUND_PORT,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The accept-queue pair the test accept loop pops from (the alktunnels
|
||||
/// listener test's shape: a VecDeque + Notify — one accepted pair per
|
||||
/// dispatcher call).
|
||||
pub type ComposedAcceptQueue = (
|
||||
Arc<
|
||||
tokio::sync::Mutex<
|
||||
std::collections::VecDeque<(
|
||||
alktunnels::params::SubstrateAddr,
|
||||
alktunnels::producer::TargetHandle,
|
||||
)>,
|
||||
>,
|
||||
>,
|
||||
Arc<tokio::sync::Notify>,
|
||||
);
|
||||
|
||||
pub fn composed_fresh_queue() -> ComposedAcceptQueue {
|
||||
(
|
||||
Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new())),
|
||||
Arc::new(tokio::sync::Notify::new()),
|
||||
)
|
||||
}
|
||||
|
||||
/// An accepted "connection" for the test accept loop: a duplex pair
|
||||
/// with an echo task on the far end (the app server's socket stand-in
|
||||
/// — the data plane's echo confirms the full round trip).
|
||||
fn composed_accepted_halves(
|
||||
peer_addr: (&str, u16),
|
||||
) -> (
|
||||
alktunnels::params::SubstrateAddr,
|
||||
alktunnels::producer::TargetHandle,
|
||||
) {
|
||||
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
|
||||
let (mut t_read, mut t_write) = tokio::io::split(target_side);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match t_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => {
|
||||
if t_write.write_all(&buf[..n]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (c_read, c_write) = tokio::io::split(consumer_side);
|
||||
(
|
||||
alktunnels::params::SubstrateAddr::HostPort {
|
||||
host: peer_addr.0.to_string(),
|
||||
port: peer_addr.1,
|
||||
},
|
||||
alktunnels::producer::TargetHandle {
|
||||
read: Box::new(c_read),
|
||||
write: Box::new(c_write),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The test accept loop (the dispatcher invokes the closure per
|
||||
/// accept): pop one accepted pair, or park until notified.
|
||||
pub fn composed_queue_loop(
|
||||
queue: ComposedAcceptQueue,
|
||||
) -> alktunnels::producer::BindFirstAcceptLoop {
|
||||
let (queue, notify) = queue;
|
||||
Arc::new(move |_listener| {
|
||||
let queue = Arc::clone(&queue);
|
||||
let notify = Arc::clone(¬ify);
|
||||
Box::pin(async move {
|
||||
let popped = {
|
||||
let notified = std::pin::pin!(notify.notified());
|
||||
let mut q = queue.lock().await;
|
||||
if let Some(pair) = q.pop_front() {
|
||||
Some(pair)
|
||||
} else {
|
||||
drop(q);
|
||||
notified.await;
|
||||
let mut q = queue.lock().await;
|
||||
q.pop_front()
|
||||
}
|
||||
};
|
||||
popped.ok_or(alktunnels::TunnelEstablishError::DialFailed(
|
||||
"listener closed".to_string(),
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Push an accepted pair into the test's accept queue.
|
||||
pub async fn composed_push_accept(queue: &ComposedAcceptQueue, peer: (&str, u16)) {
|
||||
let (peer_addr, handle) = composed_accepted_halves(peer);
|
||||
queue.0.lock().await.push_back((peer_addr, handle));
|
||||
queue.1.notify_waiters();
|
||||
}
|
||||
|
||||
/// The composed suites' no-leak probe: the opener side ends with
|
||||
/// channel 0 (+ the live channels the test names); the producer side
|
||||
/// ends with channel 0, the SOCKS5 channels the test names, AND any
|
||||
/// odd id — the producer-side ADOPTED forwarded channels
|
||||
/// (opener-allocated ids, adopted inside the producer's
|
||||
/// `open_forwarded` caller; per-session ownership per alktunnels
|
||||
/// ADR-005 — their entries reap at transport EOF, not at the
|
||||
/// listener's teardown; upstream's own bind-first suites make the
|
||||
/// same concession in `no_stray_channels`).
|
||||
pub async fn assert_composed_channels(
|
||||
topo: &BindFirstTopology,
|
||||
opener_extra: &[u32],
|
||||
producer_extra: &[u32],
|
||||
) {
|
||||
let opener_ids = topo.opener.manager().channel_ids();
|
||||
let producer_ids = topo.producer_manager.channel_ids();
|
||||
let opener_ok = opener_ids
|
||||
.iter()
|
||||
.all(|id| *id == 0 || opener_extra.contains(id));
|
||||
let producer_ok = producer_ids
|
||||
.iter()
|
||||
.all(|id| *id == 0 || *id % 2 == 1 || producer_extra.contains(id));
|
||||
assert!(
|
||||
opener_ok && producer_ok,
|
||||
"channel sets: opener={opener_ids:?} producer={producer_ids:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in new issue
Block a user