- 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
182 lines
7.4 KiB
Rust
182 lines
7.4 KiB
Rust
//! The parse-only dispatch core (`w2-dispatch-decline-core`) — the
|
|
//! routing decision and the wire-decline reason shaping the
|
|
//! per-connection handler hooks speak ([`crate::handler`]).
|
|
//!
|
|
//! **Parse-only** (ADR-006 point 3 as amended): an `exec_request`
|
|
//! parses *first* — a git-parseable command routes to the git row,
|
|
//! anything else routes to the session pipe row; `pty_request` /
|
|
//! `shell_request` route to the session rows. Parsing happens before
|
|
//! dispatch and before any resource allocation — **decline first,
|
|
//! alloc never** (a leaked channel alloc is a capability leak, the
|
|
//! POC-3 stage-A rule). There is no request-less serving mode: a
|
|
//! session channel that never receives a request is never served (the
|
|
//! SOCKS-detect retired with `dynamic-forward`, ADR-003 §1a), so a
|
|
//! request-less session channel declines as unfeatured by
|
|
//! construction.
|
|
//!
|
|
//! ## The skeleton exec parse probe
|
|
//!
|
|
//! [`classify_exec`] is deliberately **skeleton-level** (service
|
|
//! prefix + path shape): it recognizes the two serving git services
|
|
//! (`git-upload-pack`, `git-receive-pack`) followed by a space and a
|
|
//! path-shaped argument (leading `'` — the vanilla-git quoted shape —
|
|
//! or `/` — unquoted absolute). Everything else routes to the session
|
|
//! pipe row, which in every build that lacks the `session` feature
|
|
//! declines: the POC-3 stage-A contract (unknown repo → collapsed
|
|
//! generic denial before any protocol byte; non-git command →
|
|
//! parse-then-fail). The full grammar (quoted/unquoted paths,
|
|
//! per-length limits from [`crate::Limits::max_exec_command_len`],
|
|
//! service-only → decline-with-reason, `git-send-pack` /
|
|
//! `git-upload-archive` → unknown-service decline) is wave-3's git
|
|
//! task, which replaces this probe wholesale — the [`ExecRoute`] rows
|
|
//! and the routing decisions around them are what it must keep.
|
|
|
|
use russh::ChannelOpenFailure;
|
|
|
|
/// Which dispatch row an `exec_request` command routes to (the
|
|
/// parse-only decision, ADR-006 point 3 as amended).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum ExecRoute {
|
|
/// A git-parseable command — routes to the git row (the `git`
|
|
/// feature gate is the row's other half; the parse is the session
|
|
/// build's gate — a session-only build declines here).
|
|
Git,
|
|
/// Everything else — routes to the session pipe row (the
|
|
/// `session` feature gate is the row's other half; unfeatured
|
|
/// builds decline here).
|
|
Pipe,
|
|
}
|
|
|
|
/// The skeleton exec parse probe — see the module docs for the exact
|
|
/// shape it recognizes and what wave 3 replaces it with.
|
|
pub(crate) fn classify_exec(command: &str) -> ExecRoute {
|
|
if is_serving_git_command(command) {
|
|
ExecRoute::Git
|
|
} else {
|
|
ExecRoute::Pipe
|
|
}
|
|
}
|
|
|
|
/// The skeleton probe's git recognition: exact serving-service prefix,
|
|
/// single separating space, path-shaped argument.
|
|
fn is_serving_git_command(command: &str) -> bool {
|
|
for service in ["git-upload-pack ", "git-receive-pack "] {
|
|
if let Some(argument) = command.strip_prefix(service)
|
|
&& is_path_shaped(argument)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// The path shape the skeleton probe accepts: a path-shaped argument
|
|
/// starts with `'` (quoted — the vanilla-git wire shape) or `/`
|
|
/// (unquoted absolute).
|
|
fn is_path_shaped(argument: &str) -> bool {
|
|
argument.starts_with('\'') || argument.starts_with('/')
|
|
}
|
|
|
|
/// The wire decline reason for a **featureless** row — a known channel
|
|
/// type this build serves nothing of (unfeatured or never-feature):
|
|
/// `administratively prohibited`, the vanilla-observable policy
|
|
/// refusal (POC-1 §5 — channel-scoped, auth intact, no hangs). A
|
|
/// never-feature (`x11`, auth-agent) and an unfeatured feature row
|
|
/// share this class honestly: without the feature there is no
|
|
/// capability to configure, so nothing distinguishable exists.
|
|
pub(crate) fn unfeatured_open_decline() -> ChannelOpenFailure {
|
|
ChannelOpenFailure::AdministrativelyProhibited
|
|
}
|
|
|
|
/// The wire decline reason for the **tunnel seam stub** — the row is
|
|
/// feature-gated `tunnel` but the route-1 bridge (wave 3) does not
|
|
/// exist yet, so the fabric open never happened: `connect failed`, the
|
|
/// explicit reject shape ADR-006 decision 1 pins (accept only after
|
|
/// the fabric open succeeds; failure maps to an explicit SSH
|
|
/// `ConnectFailed`). Wave 3 replaces the stub with the real
|
|
/// accept-after-open flow.
|
|
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};
|
|
|
|
fn route(command: &str) -> bool {
|
|
classify_exec(command) == ExecRoute::Git
|
|
}
|
|
|
|
/// - *Contract stamp:* ADR-006 decision 2 (the exec-only git parse
|
|
/// — the vanilla wire shape `git-upload-pack '/owner/demo'`).
|
|
#[test]
|
|
fn vanilla_quoted_upload_pack_parse_is_git() {
|
|
assert!(route("git-upload-pack '/owner/demo'"));
|
|
}
|
|
|
|
/// - *Contract stamp:* ADR-006 decision 2 (the receive-pack half
|
|
/// of the exec-only git parse).
|
|
#[test]
|
|
fn receive_pack_parse_is_git() {
|
|
assert!(route("git-receive-pack '/owner/demo'"));
|
|
}
|
|
|
|
/// - *Contract stamp:* POC-3 §2 (the unquoted-absolute row of the
|
|
/// grammar table: `git-upload-pack /r`).
|
|
#[test]
|
|
fn unquoted_absolute_pack_parse_is_git() {
|
|
assert!(route("git-upload-pack /r"));
|
|
}
|
|
|
|
/// - *Contract stamp:* POC-3 §2 (service-only → declined — SSH
|
|
/// exec must carry the repo; the skeleton routes it to the pipe
|
|
/// row, which declines in every git-less build; wave 3 gives the
|
|
/// decline its own reason).
|
|
#[test]
|
|
fn service_only_parse_is_not_git() {
|
|
assert!(!route("git-upload-pack"));
|
|
assert!(!route("git-receive-pack "));
|
|
}
|
|
|
|
/// - *Contract stamp:* POC-3 §2 (unknown service → declined
|
|
/// honestly; the skeleton routes it off the git row).
|
|
#[test]
|
|
fn unknown_services_parse_is_not_git() {
|
|
assert!(!route("git-send-pack '/r'"));
|
|
assert!(!route("git-upload-archive '/r'"));
|
|
}
|
|
|
|
/// - *Contract stamp:* mapping.md §Session-channel dispatch
|
|
/// (non-git → parse-then-fail, the pipe row).
|
|
#[test]
|
|
fn non_git_commands_parse_to_the_pipe_row() {
|
|
assert!(!route("uptime"));
|
|
assert!(!route(""));
|
|
assert!(!route("git-upload-packrel"));
|
|
assert!(!route("git-upload-pack rel"));
|
|
assert!(!route("GIT-UPLOAD-PACK '/r'"));
|
|
assert!(!route("git-upload-pack$ '/r'"));
|
|
assert!(!route("git-upload-pack '/r'"));
|
|
}
|
|
|
|
/// The probe is total: it returns a route for any input, so the
|
|
/// dispatch never allocates on a parse failure.
|
|
#[test]
|
|
fn probe_is_total_over_arbitrary_input() {
|
|
assert!(!route("\u{0}\u{1}"));
|
|
assert!(route("git-upload-pack '/\u{e9}'"));
|
|
}
|
|
}
|