feat(handler): enforce the in-crate concurrent-channel cap at the open surface (w2-bounded-resources)

- new private channel_cap module: per-session active-channel count;
  admit (check+increment at accept sites) / has_room / saturating
  release, single-writer (inline packet-loop hooks), unit-tested
- handler wires the cap at every channel_open_* hook: the session
  accept path admits-and-counts at the accept point; the tunnel rows
  cap-gate before the seam stub (a counted-but-unopened stub would
  leak the cap); x11 never consumes a count; the count releases
  through the new channel_close hook
- dispatch::cap_exceeded_open_decline -> AdministrativelyProhibited
  (the assembly's policy ceiling; OpenSSH MaxSessions parity)
- bounded-resources contract rows: cap reached -> clean decline,
  close -> recovery open succeeds (wire-order deterministic),
  zero cap refuses without poisoning, exhausted cap answers the
  tunnel row with the cap reason; max_auth_attempts verified
  behaviorally (within-ceiling failures keep the advertisement, the
  past-ceiling attempt is the NoMoreAuthMethodsAvailable teardown);
  window/packet/buffer/inactivity knobs recorded as covered by the
  config-mapping tests
- max_exec_command_len stays carried, not enforced (wave-3 grammar)

Verification: cargo test (default), --features session / git /
tunnel / --all-features, clippy --all-targets (-D warnings, also
--all-features), fmt --check, cargo doc --no-deps - all green
This commit is contained in:
glm-5.3-flash committed 2026-10-28 11:40:29 +00:00
1 parent 2e5a7c9cee
commit 196289f5ad
6 files changed
+658 -26

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
//! The in-crate concurrent-channel cap (`w2-bounded-resources`) — the
//! bound itself, carried by the per-connection handler
//! ([`crate::handler`]) and enforced at every `channel_open_*` accept
//! path (`overview.md` §Bounded-resources policy: "concurrent channels
//! per session — ours (in-crate) + per-identity policy when bridged";
//! AGENTS.md convention 9 — SSH servers are internet-facing; unbounded
//! loops and unbounded buffers are bugs).
//!
//! ## Shape
//!
//! [`ChannelCap`] is a per-session count of active channels. Admission
//! ([`ChannelCap::admit`]) is the check-and-increment the accept paths
//! call: room under the cap admits (the count rises), otherwise the
//! open is refused — a clean decline, **never queued, never half-open**
//! (the fail-closed posture; the refusal reason is
//! [`crate::dispatch::cap_exceeded_open_decline`]). Release
//! ([`ChannelCap::release`]) fires from the `channel_close` hook, so
//! the cap recovers as channels close.
//!
//! ## Why no lock
//!
//! The count is **handler-owned, not shared**: russh at this pin runs
//! every handler hook inline in the session's packet loop with
//! exclusive `&mut self` access (ADR-006 decision 4 puts serving
//! paths on spawned tasks, but those report through the session
//! handle — their channel closes cycle back through the packet loop
//! as `CHANNEL_CLOSE`, firing the same `channel_close` hook). The
//! only writer is that single loop, so the plain field is sound and a
//! lock would only mislead about shared mutation that does not exist.
//! The per-identity policy layer (when the alkcall bridge carries it)
//! is additional and arrives with the bridge, not here.
//!
//! ## Soundness of the decrement invariant
//!
//! Every `channel_close` delivery corresponds to exactly one
//! previously-admitted open: russh only routes `CHANNEL_CLOSE` for
//! channels it considers established, and a declined open never
//! establishes one — so no refusal path can ever decrement for a
//! channel it never counted. A `CHANNEL_CLOSE` is also consumed at
//! most once (russh removes the channel before the hook), so one
//! admitted channel decrements exactly once. [`ChannelCap::release`]
//! additionally saturates, making a stray decrement impossible to
//! manufacture capacity with. Channels never closed before session
//! teardown stay counted until the handler dies with the session —
//! per-session state, gone with the connection.
/// The per-session active-channel count for the in-crate
/// concurrent-channel cap — see the module docs for the shape, the
/// single-writer reasoning, and the decrement invariant.
#[derive(Debug, Default)]
pub(crate) struct ChannelCap {
active: usize,
}
impl ChannelCap {
/// Whether an open has room under `cap` — the pure check, for the
/// paths whose decline is ordered *before* the accept decision
/// (this wave: the tunnel rows, whose seam stub declines without
/// ever opening; wave 3 moves the admission call to the
/// accept-after-fabric-open point).
pub(crate) fn has_room(&self, cap: usize) -> bool {
self.active < cap
}
/// The accept-path admission decision: room under `cap` increments
/// the count and admits; otherwise refuses (fail-closed — the
/// caller answers the open with a clean decline). `cap == 0`
/// refuses every open.
pub(crate) fn admit(&mut self, cap: usize) -> bool {
if self.has_room(cap) {
self.active += 1;
true
} else {
false
}
}
/// A counted channel closed — the `channel_close` hook's
/// decrement, recovery of the cap. Saturating: a count never goes
/// below zero, so no decrement path can manufacture capacity.
pub(crate) fn release(&mut self) {
self.active = self.active.saturating_sub(1);
}
}
#[cfg(test)]
mod tests {
use super::ChannelCap;
const CAP: usize = 2;
/// - *Contract stamp:* AGENTS.md convention 9 / `overview.md`
/// §Bounded-resources policy (the in-crate cap admits opens under
/// the configured ceiling and refuses beyond it).
#[test]
fn admits_up_to_the_cap_then_refuses() {
let mut cap = ChannelCap::default();
assert!(cap.admit(CAP));
assert!(cap.admit(CAP));
assert!(!cap.admit(CAP), "beyond the cap: refused");
assert!(!cap.has_room(CAP));
}
/// - *Contract stamp:* the task's cap mechanics (a close recovers
/// the cap — the count decrements on close).
#[test]
fn release_recovers_the_cap() {
let mut cap = ChannelCap::default();
assert!(cap.admit(CAP));
assert!(cap.admit(CAP));
assert!(!cap.admit(CAP));
cap.release();
assert!(cap.has_room(CAP));
assert!(cap.admit(CAP), "an open succeeds again after a close");
assert!(!cap.admit(CAP));
}
/// - *Contract stamp:* the fail-closed cap posture (`cap == 0` is
/// a valid, assembly-tunable refusal of every open — never a
/// bypass).
#[test]
fn zero_cap_refuses_every_open() {
let mut cap = ChannelCap::default();
assert!(!cap.admit(0));
cap.release();
assert!(
!cap.admit(0),
"release never manufactures capacity below zero"
);
}
/// Release saturates: a decrement for a channel never counted can
/// never manufacture capacity (the soundness half of the
/// decrement invariant this crate's own gates make unreachable).
#[test]
fn release_saturates_at_zero() {
let mut cap = ChannelCap::default();
cap.release();
cap.release();
assert_eq!(cap.active, 0);
assert!(cap.admit(CAP));
}
}
+12
View File
@@ -99,6 +99,18 @@ pub(crate) fn tunnel_seam_stub_decline() -> ChannelOpenFailure {
ChannelOpenFailure::ConnectFailed
}
/// The wire decline reason for an open beyond the **in-crate
/// concurrent-channel cap** (`overview.md` §Bounded-resources policy,
/// `w2-bounded-resources`): `administratively prohibited` — the cap is
/// the assembly's own configured policy ceiling, refused fail-closed
/// at the open (never queued, never half-open). OpenSSH's own
/// `MaxSessions` ceiling answers the same code, so vanilla clients
/// see the vanilla-shaped policy refusal (POC-1 §5: channel-scoped,
/// auth intact, no hangs).
pub(crate) fn cap_exceeded_open_decline() -> ChannelOpenFailure {
ChannelOpenFailure::AdministrativelyProhibited
}
#[cfg(test)]
mod tests {
use super::{ExecRoute, classify_exec};
+118 -20
View File
@@ -128,6 +128,24 @@
//! serving task (ADR-006 decision 4, §The spawn-and-return
//! structural constraint above); the wire shape is unchanged.
//!
//! ## Bounded resources at the handler surface (`w2-bounded-resources`)
//!
//! (AGENTS.md convention 9; `overview.md` §Bounded-resources policy —
//! the "concurrent channels per session — ours" row.) The in-crate
//! concurrent-channel cap ([`crate::channel_cap::ChannelCap`]) is
//! enforced at every `channel_open_*` hook: an accept path beyond
//! `Limits::max_concurrent_channels` is refused fail-closed at the
//! open ([`crate::dispatch::cap_exceeded_open_decline`] — never
//! queued, never half-open), and the count releases through the
//! `channel_close` hook so the cap recovers. The order of the gates:
//! the feature gate first (an unfeatured build declines everything
//! before the cap matters), the cap gate before any accept — and
//! before the tunnel seam stub, whose decline opens nothing and so
//! must not consume a count. The `max_exec_command_len` cap stays
//! reserved for wave 3's grammar (carried by [`crate::Limits`], not
//! enforced here); the per-identity policy layer arrives with the
//! alkcall bridge.
//!
//! ## Test surface
//!
//! The handler type is deliberately constructible outside this module
@@ -148,6 +166,7 @@ use russh::server::{
};
use russh::{Channel, ChannelId, Pty};
use crate::channel_cap::ChannelCap;
use crate::config::ServeConfig;
use crate::dispatch;
use crate::error::AdapterError;
@@ -158,11 +177,15 @@ use crate::serve;
/// nothing here — [`crate::SshAdapter::handle`] builds one per accepted
/// stream and drives it through `russh::server::run_stream`.
///
/// Carries the serve-config the auth bridge and dispatch consume, and
/// the resolved identity once a publickey auth has been accepted (see
/// Carries the serve-config the auth bridge and dispatch consume, the
/// in-crate concurrent-channel cap (`ChannelCap` — see the private
/// `channel_cap` module; enforced at every `channel_open_*` accept
/// path, bounded resources, AGENTS.md convention 9), and the resolved
/// identity once a publickey auth has been accepted (see
/// [`Self::resolved_identity`] — the wave-3 seam reads it).
pub struct SessionHandler {
serve: Arc<ServeConfig>,
channel_cap: ChannelCap,
resolved_identity: Option<Identity>,
}
@@ -174,6 +197,7 @@ impl SessionHandler {
pub fn new(serve: Arc<ServeConfig>) -> Self {
Self {
serve,
channel_cap: ChannelCap::default(),
resolved_identity: None,
}
}
@@ -186,6 +210,33 @@ impl SessionHandler {
self.resolved_identity.as_ref()
}
/// The accept-path admission under the in-crate concurrent-channel
/// cap (bounded resources, `w2-bounded-resources`): room under
/// `Limits::max_concurrent_channels` admits and counts the channel;
/// beyond the cap the open is refused fail-closed — never queued,
/// never half-open. Called exactly at accept sites: a decline path
/// (unfeatured, never-feature, the tunnel seam stub) never touches
/// the count, and the only writer is this connection's packet loop
/// ([`ChannelCap`]'s single-writer reasoning).
fn admit_channel_open(&mut self) -> bool {
self.channel_cap
.admit(self.serve.limits.max_concurrent_channels)
}
/// Whether the cap is already exhausted — the gate slot for the
/// rows whose decline is ordered *before* the accept decision.
/// This wave that is the tunnel rows, whose seam stub declines
/// without ever opening a channel (a counted admission it never
/// opens would leak against the cap — declined-first-alloc-never,
/// the ADR-006 dispatch rule, applies to the budget too). Wave 3's
/// accept-after-fabric-open flow replaces the stub and moves the
/// admission ([`Self::admit_channel_open`]) to the accept point.
fn channel_cap_exhausted(&self) -> bool {
!self
.channel_cap
.has_room(self.serve.limits.max_concurrent_channels)
}
/// The ADR-004 decision 1 bridge core, structured out of the
/// [`Handler::auth_publickey`] hook so the contract tests can drive
/// it directly: the hook's russh-owned argument shape
@@ -314,10 +365,36 @@ impl russh::server::Handler for SessionHandler {
Ok(Auth::reject())
}
/// A counted channel closed — the in-crate concurrent-channel
/// cap's decrement (`w2-bounded-resources`): every fired
/// `channel_close` corresponds to exactly one previously-admitted
/// open (russh only routes `CHANNEL_CLOSE` for established
/// channels — a declined open never established one — and consumes
/// the close at most once), so the count recovers through this
/// single release point, saturating in [`ChannelCap::release`] on
/// top. The spawned serving paths never touch the count directly:
/// their closes ride the cloned handle, the client's close reply
/// cycles back through the packet loop, and this hook fires — the
/// single-writer reasoning in [`ChannelCap`].
async fn channel_close(
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
self.channel_cap.release();
Ok(())
}
/// The session-channel open gate (ADR-003 feature graph,
/// `session` ∨ `git`): unfeatured builds decline at the open —
/// never a half-open channel. A confirmed open accepts the
/// channel; the parse-only dispatch serves it through its
/// `session` ∨ `git`), then the in-crate concurrent-channel cap
/// ([`Self::admit_channel_open`] — bounded resources,
/// `w2-bounded-resources`): unfeatured builds decline at the open
/// — never a half-open channel — and a featured open beyond the
/// cap is refused fail-closed with a clean wire reason
/// ([`crate::dispatch::cap_exceeded_open_decline`]), never queued.
/// A confirmed open accepts the channel and counts it; the count
/// releases when the channel closes ([`Self::channel_close`]). The
/// parse-only dispatch serves the accepted channel through its
/// requests (nothing serves request-less — ADR-006 as amended).
async fn channel_open_session(
&mut self,
@@ -325,16 +402,22 @@ impl russh::server::Handler for SessionHandler {
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_session_channels() {
if !self.serves_session_channels() {
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.admit_channel_open() {
reply.accept().await;
} else {
reply.reject(dispatch::unfeatured_open_decline()).await;
reply.reject(dispatch::cap_exceeded_open_decline()).await;
}
Ok(())
}
/// The x11 channel open: **always declines** — a never-feature
/// (ADR-003 §5), declined in every build, never feature-gated.
/// (ADR-003 §5), declined in every build, never feature-gated. The
/// cap never applies here: a count is consumed only at an accept
/// path, and this hook never has one.
async fn channel_open_x11(
&mut self,
_channel: Channel<Msg>,
@@ -352,7 +435,10 @@ impl russh::server::Handler for SessionHandler {
/// route-1 bridge is wave-3 material, and the stub's decline is
/// the explicit `ConnectFailed` reject shape ADR-006 decision 1
/// pins (accept only after the fabric open succeeds; the skeleton
/// never opens the fabric).
/// never opens the fabric). The in-crate cap gates before the stub
/// ([`Self::channel_cap_exhausted`]): a cap-exhausted build
/// answers the cap reason — the stub opens nothing, so admission
/// moves to wave 3's real accept point.
async fn channel_open_direct_tcpip(
&mut self,
_channel: Channel<Msg>,
@@ -363,16 +449,20 @@ impl russh::server::Handler for SessionHandler {
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_tunnels() {
tunnel_row_stub(reply).await;
} else {
if !self.serves_tunnels() {
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.channel_cap_exhausted() {
reply.reject(dispatch::cap_exceeded_open_decline()).await;
return Ok(());
}
tunnel_row_stub(reply).await;
Ok(())
}
/// The `forwarded-tcpip` open gate (`tunnel` — the `-R` data
/// leg): the same gate/stub split as `direct-tcpip`. The
/// leg): the same gate/cap/stub split as `direct-tcpip`. The
/// harness's russh client cannot initiate this channel type (the
/// server opens it toward the client), so its wire rows are
/// wave-3 acceptor material; the gate itself is straight-line
@@ -387,16 +477,20 @@ impl russh::server::Handler for SessionHandler {
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_tunnels() {
tunnel_row_stub(reply).await;
} else {
if !self.serves_tunnels() {
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.channel_cap_exhausted() {
reply.reject(dispatch::cap_exceeded_open_decline()).await;
return Ok(());
}
tunnel_row_stub(reply).await;
Ok(())
}
/// The `direct-streamlocal` open gate (`tunnel`): the same
/// gate/stub split as the tcp rows. The runtime-Unix gate (OQ-11)
/// gate/cap/stub split as the tcp rows. The runtime-Unix gate (OQ-11)
/// gates *serving* on Unix; this wave the stub declines
/// everywhere, so the runtime gate has nothing to ride yet.
async fn channel_open_direct_streamlocal(
@@ -406,11 +500,15 @@ impl russh::server::Handler for SessionHandler {
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_tunnels() {
tunnel_row_stub(reply).await;
} else {
if !self.serves_tunnels() {
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.channel_cap_exhausted() {
reply.reject(dispatch::cap_exceeded_open_decline()).await;
return Ok(());
}
tunnel_row_stub(reply).await;
Ok(())
}
+1
View File
@@ -25,6 +25,7 @@
//! this gate). See also `docs/research/` and family ADR-003.
mod adapter;
mod channel_cap;
mod config;
mod dispatch;
mod error;
+95 -6
View File
@@ -1,7 +1,7 @@
---
id: w2-bounded-resources
name: Wave 2 — bounded resources (the in-crate channel cap at the handler surface)
status: pending
status: completed
depends_on: [w2-spawn-return]
scope: narrow
risk: low
@@ -39,15 +39,15 @@ not from this task.
## Acceptance Criteria
- [ ] The concurrent-channel cap is enforced at every
- [x] The concurrent-channel cap is enforced at every
`channel_open_*` hook: beyond `Limits::max_concurrent_channels`
→ clean decline; the count recovers on close
- [ ] Harness tests: cap reached → next open declines; a close → an
- [x] Harness tests: cap reached → next open declines; a close → an
open succeeds again; the cap is `Limits`-tunable
- [ ] The wired russh knobs verified behaviorally where cheap
- [x] The wired russh knobs verified behaviorally where cheap
(auth-attempt ceiling through the harness) or recorded as
covered by the config-mapping tests
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check` pass
## References
@@ -63,6 +63,95 @@ not from this task.
> To be filled by implementation agent
- **The count is handler-owned, not locked.** New private module
`src/channel_cap.rs` (`ChannelCap`): the per-session active-channel
count with `admit` (check+increment at accept sites) / `has_room`
(pure check) / `release` (saturating decrement). The only writer is
the connection's russh packet loop — every hook runs inline with
exclusive `&mut self` (ADR-006 decision 4 puts serving paths on
spawned tasks, but their closes cycle back through the packet loop
as `CHANNEL_CLOSE`, firing the hook) — so the `parking_lot`
convention's short-held *shared* state does not apply: no lock
exists because no shared mutation does. Saturating `release` means
a stray decrement cannot manufacture capacity.
- **Gate ordering** (handler.rs): feature gate first, then the cap.
Session-channel opens admit-and-count exactly at the accept point.
The tunnel rows check the cap *before* the seam stub — the stub
declines without opening a channel, so a counted admission it never
opens would leak against the cap forever (decline-first-alloc-never
applies to the budget, not just capability). Wave 3's
accept-after-fabric-open flow replaces the stub and moves admission
to the real accept point; the wire behavior this wave: a
cap-exhausted tunnel row answers the cap reason instead of the
seam's `ConnectFailed` (pinned by a row, not by convention). x11
declines in every build and never consumes a count.
- **Decline reason**: `dispatch::cap_exceeded_open_decline()` →
`AdministrativelyProhibited` — the cap is the assembly's configured
policy ceiling; OpenSSH's own `MaxSessions` answers the same code,
so vanilla clients see the vanilla-shaped policy refusal (POC-1 §5).
- **Decrement invariant**: every fired `channel_close` corresponds to
exactly one previously-admitted open — russh only routes
`CHANNEL_CLOSE` for channels it considers established (a declined
open never established one) and consumes the close at most once
(the channel is removed from russh's map before the hook).
- **Recovery observability (russh-pin fact, recorded in the test
doc)**: a *client-initiated* channel close is not observable
client-side at this pin — russh removes a locally-closed channel
from its client map before the server's `CHANNEL_CLOSE` arrives
back, so `Channel::wait()` never delivers the close (the harness's
`await_close` observes only *server-initiated* closes, which is what
the spawn-return/dispatch rows use). The recovery row therefore
rests on wire ordering (deterministic): close and open ride the
client loop's single FIFO event queue, are written in that order,
and the server's packet loop processes them in order — the counted
release precedes the recovery admission; had the release not
happened, the recovery open declines and the row fails.
- **`max_auth_attempts` verified behaviorally**: ceiling `2`
(tunable) → two within-ceiling failures stay ordinary
`AuthResult::Failure` riding the `{publickey}` advertisement; the
attempt past the ceiling is answered with the server's
`Disconnect::NoMoreAuthMethodsAvailable`, whose client-side shape is
russh's synthesized `Failure` with an *empty* remaining-methods set
(no reply came; the advertisement is gone) — the session ends
cleanly server-side (row: `tests/bounded_resources.rs`
`auth_attempt_ceiling_disconnects_an_exceeding_client`).
- **The size/window/clock knobs recorded as config-mapping-covered**:
`window_size`, `maximum_packet_size`, `channel_buffer_size`,
`max_auth_attempts` mapping and the always-set `inactivity_timeout`
are pinned one-for-one by the adapter's config-mapping unit tests
(`adapter.rs` `limits_knobs_map_one_for_one_with_inactivity_always_set`,
`default_limits_map_to_the_documented_postures`); the client
direction's keepalive/wall-clock postures are pinned by the harness
(`client_config_maps_the_limits_postures`). Re-driving them through
the wire buys no new information (russh reads the `Config` fields it
owns); not cheaply behavioral, so recorded, per the task's split.
- **`max_exec_command_len` stays carried, not enforced** (wave 3's
grammar owns it) — unchanged from `src/config.rs`/`src/dispatch.rs`
documentation; nothing here consumes it.
- **Verification**: `cargo test` (default), and `--features session`
/ `git` / `tunnel` / `--all-features` (the feature-gated cap rows
run in the builds whose opens can be accepted), `cargo clippy
--all-targets -- -D warnings` (+ `--all-features`),
`cargo fmt --check`, `cargo doc --no-deps` — all green.
## Summary
> To be filled on completion
> To be filled on completion
Landed the in-crate concurrent-channel cap at the handler surface:
new private `channel_cap` module (count + admit/release semantics,
unit-tested), wired into `SessionHandler`'s every open hook — the
session accept path admits-and-counts at the accept, the tunnel rows
cap-gate before the seam stub, x11 is unaffected — and decremented
through the new `channel_close` hook (single release point, single
writer). Cap-exceeded opens decline fail-closed with
`AdministrativelyProhibited` (`dispatch::cap_exceeded_open_decline`,
OpenSSH `MaxSessions` parity). Contract rows in
`tests/bounded_resources.rs`: cap reached → clean decline; close →
open succeeds again (wire-order deterministic); zero cap refuses
everything without poisoning; exhausted cap answers the tunnel row
with the cap reason; the `max_auth_attempts` ceiling verified
behaviorally through the harness. The per-identity policy layer stays
with the wave-3 bridge; the per-length exec-parse cap stays the
grammar's. All five feature-build test runs, clippy (both) and fmt
green.
+288
View File
@@ -0,0 +1,288 @@
//! The `w2-bounded-resources` contract rows — the bounded-resources
//! policy enforced through the real wire via the loopback harness: the
//! in-crate concurrent-channel cap at every `channel_open_*` accept
//! path, its recovery on close, its `Limits` tunability, and the
//! behavioral verification of the wired `max_auth_attempts` russh knob
//! (the ceiling disconnects an exceeding client). The size/window/
//! buffer knobs (`window_size`, `maximum_packet_size`,
//! `channel_buffer_size`), the inactivity wall clock and the remaining
//! auth-counting posture ride the config-mapping tests
//! (`adapter.rs` `#[cfg(test)]` + the harness's own
//! `client_config_maps_the_limits_postures`) — recorded, not
//! re-proven on the wire here.
//!
//! The cap rows are feature-gated to the builds whose opens can be
//! accepted (session channels need `session` or `git`; the
//! tunnel-gate ordering row additionally needs `tunnel`), per the
//! `dispatch_core.rs` feature-build convention.
#![allow(dead_code)]
mod common;
use std::sync::Arc;
use alkcall::core::auth::IdentityProvider;
use common::harness::{
DUPLEX_CAPACITY, HarnessClientHandler, LoopbackSession, authenticate_publickey, client_config,
drive, serve_config,
};
use common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity};
use russh::{ChannelOpenFailure, Error, MethodSet};
use alkssh::{Limits, SshAdapter};
fn registered_provider(fingerprints: &[&str]) -> Arc<MemIdentityProvider> {
let provider = Arc::new(MemIdentityProvider::new());
for (index, registered) in fingerprints.iter().enumerate() {
provider.register(registered, identity(&format!("user-{index}"), &["ssh"]));
}
provider
}
/// Mounts an authenticated loopback session over the doubles with the
/// row's [`Limits`] — the bounded-resources rows' driver.
async fn authenticated_with(limits: Limits) -> LoopbackSession {
let provider = registered_provider(&[fixture("alpha").fingerprint]);
let adapter = SshAdapter::new(serve_config(
provider as Arc<dyn IdentityProvider>,
&[fixture("bravo")],
limits,
))
.await
.expect("adapter constructs");
let mut session = drive(
adapter,
Arc::new(MemHostKeyPolicy::with_default(true)),
limits,
)
.await
.expect("the loopback mounts");
let result = authenticate_publickey(&mut session.client, "anyone", fixture("alpha")).await;
assert!(
result.success(),
"the fixture identity authenticates before every bounded-resources row"
);
session
}
/// Matches an open failure's SSH reason — the wire decline reason the
/// cap-gate rows assert.
fn open_failure(err: Error, expected: ChannelOpenFailure, context: &str) {
assert!(
matches!(
&err,
Error::ChannelOpenFailure(reason) if *reason == expected
),
"{context}: expected open decline with {expected:?}, got {err:?}"
);
}
/// - *Contract stamp:* `overview.md` §Bounded-resources policy (the
/// auth-attempts row — the `max_auth_attempts` knob is behavioral,
/// verified where cheap: the ceiling disconnects an exceeding
/// client) + ADR-004 decision 6 (the mapping in `adapter.rs` makes
/// the knob the counting ceiling; within the ceiling, failures stay
/// ordinary `AuthResult::Failure` riding the publickey-only
/// advertisement).
///
/// Drive: `max_auth_attempts: 2` (Limits-tunable, not the 10 default),
/// three unregistered keys. The first two rejections stay ordinary
/// failures with the advertisement intact; the third request past the
/// ceiling is answered with the server's
/// `Disconnect::NoMoreAuthMethodsAvailable` — client-side, the
/// in-flight attempt sees russh's synthesized `Failure` with the
/// **empty** remaining-methods set (no reply ever came; the
/// advertisement is gone). The session ends cleanly server-side.
#[tokio::test]
async fn auth_attempt_ceiling_disconnects_an_exceeding_client() {
use russh::MethodKind;
use russh::keys::PrivateKeyWithHashAlg;
let provider = registered_provider(&[fixture("alpha").fingerprint]);
let limits = Limits {
max_auth_attempts: 2,
..Limits::default()
};
let adapter = SshAdapter::new(serve_config(
provider as Arc<dyn IdentityProvider>,
&[fixture("bravo")],
limits,
))
.await
.expect("adapter constructs");
let (client_io, server_io) = tokio::io::duplex(DUPLEX_CAPACITY);
let server_task = tokio::spawn(async move { adapter.handle(server_io).await });
let mut client = russh::client::connect_stream(
client_config(limits),
client_io,
HarnessClientHandler::new(Arc::new(MemHostKeyPolicy::with_default(true))),
)
.await
.expect("the in-process pair connects");
let normal_advertisement = MethodSet::from(&[MethodKind::PublicKey][..]);
let within = authenticate_publickey(&mut client, "anyone", fixture("delta")).await;
assert!(
!within.success(),
"within the ceiling, a rejection stays an ordinary failure: {within:?}"
);
assert_eq!(
remaining_ads(&within),
Some(normal_advertisement.clone()),
"the within-ceiling failure still carries the advertisement"
);
let last_within = authenticate_publickey(&mut client, "anyone", fixture("charlie")).await;
assert!(
!last_within.success(),
"the ceiling's last rejection still rides as an ordinary failure: {last_within:?}"
);
assert_eq!(
remaining_ads(&last_within),
Some(normal_advertisement),
"the advertisement survives every within-ceiling rejection"
);
let exceeding = async {
let key = Arc::new(fixture("bravo").private_key());
client
.authenticate_publickey("anyone", PrivateKeyWithHashAlg::new(key, None))
.await
}
.await;
match &exceeding {
Ok(result) => {
assert!(
!result.success(),
"the attempt past the ceiling never re-answers auth: {result:?}"
);
assert_eq!(
remaining_ads(result),
Some(MethodSet::empty()),
"the past-ceiling answer is the torn-down shape: no advertisement rides"
);
}
Err(_) => {
// The client loop already exited on the disconnect — the
// equally-valid torn-down shape (the send side is gone).
}
}
drop(client);
server_task
.await
.expect("server task ends")
.expect("the ceiling disconnect ends the session cleanly");
}
/// The remaining-methods set of an [`AuthResult`] — `None` on success
/// (the within-ceiling vs past-ceiling observation shape).
fn remaining_ads(result: &russh::client::AuthResult) -> Option<russh::MethodSet> {
match result {
russh::client::AuthResult::Failure {
remaining_methods, ..
} => Some(remaining_methods.clone()),
_ => None,
}
}
#[cfg(any(feature = "session", feature = "git"))]
#[tokio::test]
async fn concurrent_channel_cap_declines_and_recovers_over_the_wire() {
use common::harness::open_session_channel;
let session = authenticated_with(Limits {
max_concurrent_channels: 2,
..Limits::default()
})
.await;
let first = open_session_channel(&session.client)
.await
.expect("the first open admits under the cap");
let _second = open_session_channel(&session.client)
.await
.expect("the second open admits under the cap");
let err = open_session_channel(&session.client)
.await
.expect_err("the third open is refused at the cap — never queued");
open_failure(
err,
ChannelOpenFailure::AdministrativelyProhibited,
"the cap-exceeded open",
);
first.close().await.expect("the close rides the wire");
// The close reply is not observable client-side at this pin: russh
// removes a locally-closed channel from its map before the
// server's CHANNEL_CLOSE arrives back, so no message — and no
// `None` — is ever delivered (the harness's `await_close` only
// observes *server-initiated* closes). The recovery observation is
// the open itself, and it is deterministic: close and open ride
// the client loop's single FIFO event queue, they are written in
// that order on the wire, and the server's packet loop processes
// them in order — the counted close releases before the recovery
// open is admitted. Had the release not happened, this open
// declines and this expect fails.
let _third = open_session_channel(&session.client)
.await
.expect("a close recovers the cap; the next open succeeds again");
session.unmount().await;
}
#[cfg(any(feature = "session", feature = "git"))]
#[tokio::test]
async fn zero_cap_refuses_every_open_and_poisons_nothing() {
use common::harness::open_session_channel;
let session = authenticated_with(Limits {
max_concurrent_channels: 0,
..Limits::default()
})
.await;
for attempt in 1..=2 {
let err = open_session_channel(&session.client)
.await
.expect_err("a zero cap refuses the very first open — no half-open, no queue");
open_failure(
err,
ChannelOpenFailure::AdministrativelyProhibited,
&format!("the zero-cap open #{attempt}"),
);
}
session.unmount().await;
}
#[cfg(all(feature = "tunnel", any(feature = "session", feature = "git")))]
#[tokio::test]
async fn an_exhausted_cap_answers_the_tunnel_row_with_the_cap_reason() {
use common::harness::{open_direct_tcpip, open_session_channel};
let session = authenticated_with(Limits {
max_concurrent_channels: 1,
..Limits::default()
})
.await;
let _first = open_session_channel(&session.client)
.await
.expect("the first open admits — the cap is now exhausted");
let err = open_direct_tcpip(&session.client, "internal.example", 5432)
.await
.expect_err("the cap gates the tunnel row before the seam stub");
open_failure(
err,
ChannelOpenFailure::AdministrativelyProhibited,
"the cap-exhausted tunnel open",
);
session.unmount().await;
}