From 196289f5ad0eabc564aee7ec34c25739cc0f6eb9 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 28 Oct 2026 11:40:29 +0000 Subject: [PATCH] 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 --- src/channel_cap.rs | 144 ++++++++++ src/dispatch.rs | 12 + src/handler.rs | 138 +++++++-- src/lib.rs | 1 + tasks/implementation/w2-bounded-resources.md | 101 ++++++- tests/bounded_resources.rs | 288 +++++++++++++++++++ 6 files changed, 658 insertions(+), 26 deletions(-) create mode 100644 src/channel_cap.rs create mode 100644 tests/bounded_resources.rs diff --git a/src/channel_cap.rs b/src/channel_cap.rs new file mode 100644 index 0000000..fa9f220 --- /dev/null +++ b/src/channel_cap.rs @@ -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)); + } +} diff --git a/src/dispatch.rs b/src/dispatch.rs index b615c4b..396ed16 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -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}; diff --git a/src/handler.rs b/src/handler.rs index f3245b8..b390faa 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -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, + channel_cap: ChannelCap, resolved_identity: Option, } @@ -174,6 +197,7 @@ impl SessionHandler { pub fn new(serve: Arc) -> 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, @@ -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, @@ -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(()) } diff --git a/src/lib.rs b/src/lib.rs index 95ad99b..4f25598 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/tasks/implementation/w2-bounded-resources.md b/tasks/implementation/w2-bounded-resources.md index a21b3f9..cf558ff 100644 --- a/tasks/implementation/w2-bounded-resources.md +++ b/tasks/implementation/w2-bounded-resources.md @@ -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 \ No newline at end of file +> 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. \ No newline at end of file diff --git a/tests/bounded_resources.rs b/tests/bounded_resources.rs new file mode 100644 index 0000000..34c5929 --- /dev/null +++ b/tests/bounded_resources.rs @@ -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 { + 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, + &[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, + &[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 { + 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; +}