//! The pure configuration surface: the shapes every later wave's //! constructors take (serve-config, dial-config, [`Limits`]). Types //! only: russh `Config` construction is wave-2 material, dialing lands //! at wave 4; nothing here touches the wire or the vault. //! //! Two directions, one budget model (`overview.md` §Public surface): //! //! - [`ServeConfig`]: the server direction's assembly-supplied serving //! configuration: the OQ-08 fields ([`ServeConfig::shell_argv`] / //! [`ServeConfig::exec_wrapper_argv`]), the injected identity, //! host-key and tunnel handles, the per-feature family-seam handles //! (the wave-3 extension, where [`SessionSeam`] carries the alktty //! backends map + backend selector + ownership provider; //! [`GitSeam`] carries the alkgit backend quartet + the caller-owned //! pipeline + the push-options flag), the OQ-09 `cwd`/`env` values, //! and the [`Limits`]. //! - [`DialConfig`]: the client direction's dial configuration //! (`client.md` §The dial surface): address, `username`, key source, //! host-key policy, and the bounded-resource knobs (carried by //! [`Limits`]). //! //! Crate rules this module encodes: //! //! - **Unset-declines, no crate defaults** (OQ-08): the OQ-08 fields //! are `Option` and their unset semantics live in the type shape, //! an unset field makes its row decline with a clean error at //! request time (wave-2/3 consumption), never a fallback, never //! construction-time validation (`error.rs`'s no-config-error //! contract). No construction-time validation exists in this //! module. The wave-3 seam handles extend the same rule per //! feature: [`ServeConfig::session_seam`] unset → the session rows //! decline even though the `session` feature is on; //! [`ServeConfig::git_seam`] unset → the git row declines even //! though `git` is on (the decline matrix's config-driven notes; //! the feature alone configures nothing). The tunnel branch carries //! its flat `Option>`, because an egress-only //! (route-1-only) door needs no struct for leg 1, and the `-R` //! listen leg's registration seam landed with `w3-tunnel-listen` //! (`ServeConfig::tunnel_listen_seam`, the wave-3 seam-config's //! second course-correction; the shape decision recorded on //! [`TunnelListenSeam`]: unset → the listen rows decline even with //! a fabric leg present, because the door-side inbound-op serving //! loop only exists where the assembly wired it). //! - **Injected handles, never crate-resolved identity or trust**: //! the [`IdentityProvider`], [`HostKeySource`] and //! [`HostKeyPolicy`] handles are `Arc` the assembly //! supplies; the crate holds no identity model, no trust policy and //! no vault lifecycle of its own. A git-only tunnel build simply //! leaves [`ServeConfig::channel_client`] absent. //! - **Bounded resources everywhere** (convention 9): every session //! in both directions carries a [`Limits`]; there is no //! unbounded-loop or unbounded-buffer configuration shape the //! assembly can build from these types. //! //! Knob-name parity with the russh 0.65 pin (ADR-002): //! `window_size`, `maximum_packet_size`, `channel_buffer_size`, //! `inactivity_timeout`, `keepalive_interval`, `keepalive_max`, //! `max_auth_attempts`; wave-2/wave-4 map these fields into the russh //! `Config` verbatim. russh's rekey `Limits` stay russh-enforced at //! its own RFC-4253 §9 defaults (wave-2); they are not carried here, //! since this module's [`Limits`] is the crate's in-crate + mapped-knob //! budget model. use std::collections::BTreeMap; #[cfg(feature = "session")] use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; #[cfg(feature = "tunnel")] use crate::tunnel_map::ListenRouter; use alkcall::channels::client::ChannelClient; use alkcall::core::auth::IdentityProvider; #[cfg(feature = "session")] use alkcall::core::ownership::OwnershipProvider; #[cfg(feature = "git")] use alkgit::{GitPackGen, GitPackIngest, GitRefs, GitRegistry}; #[cfg(feature = "session")] use alktty::TtyBackend; use crate::host_key::HostKeyPolicy; use crate::key_source::{HostKeySource, KeySource}; /// The bounded-resources budget model (`overview.md` /// §Bounded-resources policy): the in-crate caps plus the russh-knob /// mapping with the documented default postures. Every session, /// server or client direction, carries one (`AGENTS.md` convention /// 9); there is no unbounded configuration shape. /// /// Carried by value (`Copy`): a [`Limits`] is plain tuning data, and /// wave-2/4 copies it into the russh `Config` construction. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Limits { /// The crates' own cap on concurrent channels per session; /// opened channels beyond the cap are declined (fail-closed /// refuse, never queue). The per-identity policy layer (when the /// alkcall bridge carries it) is additional and comes with the /// bridge itself, not from this field. Assembly-tunable. /// /// Default: `DEFAULT_MAX_CONCURRENT_CHANNELS`. pub max_concurrent_channels: usize, /// The parse-input cap for the wave-3 exec grammar: a wire exec /// command string longer than this declines before any parse or /// resource allocation (decline-first, alloc-never, the ADR-006 /// dispatch contract). Assembly-tunable. /// /// Default: `DEFAULT_MAX_EXEC_COMMAND_LEN`. pub max_exec_command_len: usize, /// Maximal number of allowed authentication attempts, mapped to /// russh's `max_auth_attempts` (server direction). Default `10` /// per ADR-004 decision 6: the OpenSSH counting `none`-probe /// consumes one attempt, so `10` leaves `IdentitiesOnly`-style /// clients at least nine real key attempts. pub max_auth_attempts: usize, /// The initial channel receive window, mapped to russh's /// `window_size` (per-channel memory bound). /// /// Default: 2 MiB (russh's own default posture, kept). pub window_size: u32, /// The maximal size of a single SSH packet, mapped to russh's /// `maximum_packet_size` (per-channel record-size bound). /// /// Default: 32 KiB (russh's own default posture, kept). pub maximum_packet_size: u32, /// Per-channel unprocessed-message buffer, mapped to russh's /// `channel_buffer_size` (the backpressure bound); messages past /// it propagate backpressure to the TCP stream, never an /// unbounded buffer. /// /// Default: 100 (russh's own default posture, kept). pub channel_buffer_size: usize, /// The session wall clock; the connection is garbage-collected /// after this long with no activity. Mapped to russh's /// `Option` as always-set (`Some(value)`) in /// wave 2/4: the bounded-resources policy's "set /// (assembly-tunable)" posture; the crate never constructs a /// session with no wall clock. Assembly-tunable. /// /// Default: `DEFAULT_INACTIVITY_TIMEOUT`. pub inactivity_timeout: Duration, /// Client-direction keepalive interval (cloud fleets): the /// dead-peer probe, mapped to russh's `keepalive_interval`. /// /// Default: `Some(15 s)`, the reverse-runner-evidence posture /// (OpenSSH `ServerAliveInterval=15` parity): with russh's own /// default (`None`) a dead NAT'd connection sits unnoticed until /// an exec health check fails (`reverse-runner.md` lesson 3). /// `None` is available to the assembly but is not the posture. pub keepalive_interval: Option, /// How many unanswered keepalives close the connection, mapped /// to russh's `keepalive_max`. /// /// Default: `3` (OpenSSH `ServerAliveCountMax=3` parity, the /// reverse-runner-evidence posture). pub keepalive_max: usize, } /// The crate default for [`Limits::max_concurrent_channels`]: a /// conservative per-session channel cap for the door shapes alkssh /// serves (the git door's few exec channels; tunnel legs are /// separate channels and fit well inside it). pub const DEFAULT_MAX_CONCURRENT_CHANNELS: usize = 64; /// The crate default for [`Limits::max_exec_command_len`]: generous /// room for the git exec row's command strings (the /// `git-upload-pack '/repo'` grammar) plus arbitrary non-git /// `ssh host "cmd"` commands, while staying far below the wire's /// 32 KiB packet bound. pub const DEFAULT_MAX_EXEC_COMMAND_LEN: usize = 8192; /// The crate default for [`Limits::inactivity_timeout`]: the 10-minute /// wall clock (russh's server default posture; an assembly that /// serves long-idle-but-alive sessions tunes it, together with the /// keepalive knobs on the client direction). pub const DEFAULT_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(600); /// The client-direction keepalive default posture /// (`Limits::keepalive_interval`): 15 seconds, per the /// reverse-runner evidence. pub const DEFAULT_KEEPALIVE_INTERVAL: Option = Some(Duration::from_secs(15)); /// The crate default for [`Limits::keepalive_max`]: 3 unanswered /// probes. pub const DEFAULT_KEEPALIVE_MAX: usize = 3; impl Default for Limits { fn default() -> Self { Self { max_concurrent_channels: DEFAULT_MAX_CONCURRENT_CHANNELS, max_exec_command_len: DEFAULT_MAX_EXEC_COMMAND_LEN, max_auth_attempts: 10, window_size: 2_097_152, maximum_packet_size: 32_768, channel_buffer_size: 100, inactivity_timeout: DEFAULT_INACTIVITY_TIMEOUT, keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, keepalive_max: DEFAULT_KEEPALIVE_MAX, } } } /// The `session` feature's alktty seam handle (`mapping.md` §The /// seams): what `drive_session[_pre_negotiated]` takes that the /// assembly composes (alktty 0.8.2's `adapter.rs:205-241` signature). /// **Shape decision** (recorded at `w3-seam-config`): a whole-`Option` /// struct per seam, not flat `Option` fields; each seam is consumed /// atomically by exactly one seam call, so a struct admits no /// half-supplied seam states and the unset-declines contract reads as /// one `Option`: **`ServeConfig::session_seam` unset → the session /// rows decline even though the `session` feature is on** (the /// decline matrix's config-driven note), checked at request time by /// the mappings; no construction-time validation. Cloned into each /// spawned serving task (all payload types `Arc` or plain data). #[cfg(feature = "session")] #[derive(Clone)] pub struct SessionSeam { /// The alktty backends map, the `TtyAdapter`-style registry keyed /// by the `NegotiateRequest.backend` selector string. **Assembled /// by the assembly**: alkssh never touches a real backend, and the /// `local` backend shapes stay alktty-side (this dep's `local` /// feature is not enabled; the map holds assembly-supplied /// implementations of alktty's [`TtyBackend`] only). pub backends: Arc>>, /// The backend **selector**, the `NegotiateRequest.backend` /// string alkssh writes into the locally-constructed negotiation /// (`w3-tty-session`'s negotiation rows; the wire command/args /// never reach it). Assembly-supplied, never wire-derived. pub backend: String, /// The ADR-050 ownership provider, passed through /// `drive_session[_pre_negotiated]` verbatim (the family ACL /// layer riding alongside; an alkcall type, no new dep). pub ownership: Option>, } #[cfg(feature = "session")] impl std::fmt::Debug for SessionSeam { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut keys: Vec<&String> = self.backends.keys().collect(); keys.sort(); f.debug_struct("SessionSeam") .field("backend_keys", &keys) .field("backend", &self.backend) .field("ownership_set", &self.ownership.is_some()) .finish() } } /// The `git` feature's alkgit seam handle (`mapping.md` §The seams): /// what `serve_duplex_resolved` takes that the assembly composes /// (alkgit 0.1.0's `duplex.rs:1412-1426` signature: the session /// budget, the four backend traits, the caller-owned pipeline, the /// push-options gate; the `limits` field landed with `w3-git-mapping` /// the first seam consumer, as a `w3-seam-config` course /// correction: alkgit's ADR-009 budget is assembly config, the door /// never invents it). Same whole-`Option`-struct decision as /// [`SessionSeam`]: /// **`ServeConfig::git_seam` unset → the git row declines even though /// the `git` feature is on**, checked at request time by the /// mappings; no construction-time validation. #[cfg(feature = "git")] #[derive(Clone)] pub struct GitSeam { /// The alkgit session budget (alkgit ADR-009's per-session bundle: /// wall clock, pack-size caps, negotiation rounds): **constructed /// by the assembly** (alkgit's own contract: the door never /// invents budget values), passed into `serve_duplex_resolved` /// verbatim (`w3-git-mapping`). Carried `Copy`; [`alkgit::Limits`]'s /// own defaults are the assembly's baseline. pub limits: alkgit::Limits, /// The repo-id resolution trait (`GitRegistry::resolve`), the /// ADR-007 pre-protocol gate's other half; alkssh never resolves /// (the collapsed-generic-denial posture lives in alkgit). pub registry: Arc, /// The ref listing + CAS transaction trait /// (`list_refs`/`apply_updates`). pub refs: Arc, /// The pack generation + negotiation-ack trait /// (`generate`/`common_haves`). pub packgen: Arc, /// The pack-preparation trait (`prepare` never applies; the /// `GitRefs::apply_updates` CAS stays the single ingestion home). pub ingest: Arc, /// The assembler's push-options flag (alkgit's ADR-013 §11 gate, /// passed through verbatim), `false` in the v1 default. pub push_options_negotiated: bool, /// The caller-owned, server-wide pipeline permit: alkgit's /// pack-pipeline concurrency bound, *shared across every session /// the serving direction takes* (the caller keeps its own handle; /// `Arc` for the share). One per `DialConfig`-independent serve /// direction, never per channel. pub pipeline: Arc, } #[cfg(feature = "git")] impl std::fmt::Debug for GitSeam { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("GitSeam") .field("limits", &self.limits) .field("registry", &"") .field("refs", &"") .field("packgen", &"") .field("ingest", &"") .field("push_options_negotiated", &self.push_options_negotiated) .field("pipeline_permits", &self.pipeline.available_permits()) .finish() } } /// The `tunnel` feature's `-R` listen-leg registration seam (the /// `w3-tunnel-listen` shape, the wave-3 seam-config's second /// course-correction, resolved per its own decision record): the /// assembly-supplied `Arc` alone cannot carry the /// door-side acceptance of the fabric's inbound forwarded opens, /// because alkcall serves those opens on the **door's channel-0 /// serving loop**, which exists only when the consumer constructed its /// `ChannelClient` with /// [`ChannelClient::from_connection_with_serving`] against a registry /// the assembly owns. The published alkcall surface has no /// post-construction way to add that loop, so the serve-config's /// tunnel handle grows this seam. /// /// Same whole-`Option`-struct decision as [`SessionSeam`]/[`GitSeam`]: /// **`ServeConfig::tunnel_listen_seam` unset → the `-R` listen rows /// (`tcpip_forward` / `streamlocal_forward` / both cancel forms) /// decline even under the `tunnel` feature with a fabric leg present** /// : an egress-only door (the POC-3 stage-C posture) is legitimate /// config; the feature alone configures nothing. Checked at request /// time by the mappings; no construction-time validation. The /// registration itself (the alkcall openable registration the seam /// fronts) is alkssh-side work at [`crate::SshAdapter::new`]; a /// failed registration is a failed construction (fail-closed, never a /// half-configured adapter). #[cfg(feature = "tunnel")] pub struct TunnelListenSeam { /// The **shared** per-connection `OperationRegistry` the fabric /// leg's inbound ops dispatch against: the exact `Arc` the /// assembly passed in this leg's `ChannelClient` construction /// (`ServingConfig.registry`): alkssh registers the /// `tunnel/forwarded` openable into it, and alkcall's dispatch /// reads it per call (the W2 post-hoc-registration posture). The /// door's own ACL entry rides the registration; arrivals lacking /// the `tunnel:forwarded` scope are gated by alkcall's /// `AccessControl::check` fail-closed. pub registry: Arc, } /// The `OperationRegistry` type behind the seam's shared dispatcher. /// (Re-imported here for the field's shape; the registration work /// lives in [`crate::tunnel_map`].) #[cfg(feature = "tunnel")] use alkcall::registry::registration::OperationRegistry; #[cfg(feature = "tunnel")] impl TunnelListenSeam { /// Builds the seam over the assembly's shared per-connection /// registry (the `ServingConfig.registry` the same fabric leg's /// `ChannelClient` was constructed with: one `Arc`, both sides). pub fn new(registry: Arc) -> Self { Self { registry } } } #[cfg(feature = "tunnel")] impl std::fmt::Debug for TunnelListenSeam { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TunnelListenSeam") .field("registry", &"") .finish() } } /// The server direction's assembly-supplied serving configuration /// (`overview.md` §Server stack): everything wave-2's serving loop /// takes at construction. Pure data + injected handles; no russh /// `Config` is built here (wave 2), no serving loop exists yet. /// /// Construction carries no validation beyond the required handles: /// the unfeatured/unset capability surfaces are structural `Option`s /// that decline at request time (the fail-closed contract); see /// [`ServeConfig::shell_argv`] and [`ServeConfig::channel_client`]. pub struct ServeConfig { /// OQ-08 field 1: the shell argv, run verbatim for /// `shell_request` (`NegotiateRequest.cmd`). No crate default: /// **unset → `shell_request` declines with a clean error, never a /// fallback**: the crate is not an sshd and has no account model /// from which a shell could be derived, so any baked-in shell /// would be deployment policy smuggled into the crate. A /// `session` build with this unset serves neither shell nor exec /// requests (the git row is unaffected, since parsing never needs a /// shell). /// /// Set to `Some(vec)` to serve the shell row (feature-gated at /// wave 2: only under the `session` feature). pub shell_argv: Option>, /// OQ-08 field 2: the exec wrapper argv (platform-neutral, e.g. /// `["/bin/sh", "-c"]`). A non-git `exec_request`'s wire command /// string is appended as the final argv element /// (`wrapper ++ [cmd]`), the vanilla `ssh host "cmd"` quoting /// semantics; command strings are never naively split. No crate /// default: **unset → the non-git exec row declines with a /// clean error, never a fallback**. /// /// Set to `Some(vec)` to serve the pipe-exec row (feature-gated /// at wave 2: only under the `session` feature). pub exec_wrapper_argv: Option>, /// The OQ-09 posture's assembly-supplied working directory for /// alktty `NegotiateRequest.cwd`, **never wire-derived** /// (`env_request` declines unconditionally; wire env is not /// family config). Unset → the backend-side default applies. pub session_cwd: Option, /// The OQ-09 posture's assembly-supplied environment for /// `NegotiateRequest.env`, **never wire-derived**. Empty by /// default. pub session_env: BTreeMap, /// The `session` feature's alktty seam handle ([`SessionSeam`], /// the backends map + backend selector + ownership provider the /// `drive_session[_pre_negotiated]` rows take). **Unset → the /// session rows decline even though the `session` feature is on** /// (the decline matrix's config-driven note; the feature alone /// configures nothing). Wave-3 extension of the wave-1 config /// surface; shape decision recorded on [`SessionSeam`]. #[cfg(feature = "session")] pub session_seam: Option, /// The `git` feature's alkgit seam handle ([`GitSeam`], the four /// backend traits + the caller-owned pipeline + the push-options /// flag the `serve_duplex_resolved` row takes). **Unset → the git /// row declines even though the `git` feature is on** (same /// postures as [`ServeConfig::session_seam`]). #[cfg(feature = "git")] pub git_seam: Option, /// The injected identity provider (alkcall's /// `IdentityProvider::resolve_from_fingerprint`), the assembly /// supplies it; the crate never resolves identities itself and /// holds no identity model. pub identity_provider: Arc, /// The injected host-key source (the wave-1 /// [`HostKeySource`] seam, decrypt-at-construction per ADR-005 /// decision 6) feeds wave-2's `Config::keys`; a load failure /// means no server is constructed, never a half-configured one. pub host_key_source: Arc, /// The tunnel route-1 bridge's family-fabric entry: a held /// alkcall `ChannelClient` (`TunnelSession::open_direct` runs on /// it). **Absent → every tunnel row declines with a clean error /// even under the `tunnel` feature** (the feature alone configures /// nothing; the fabric handle is the row's other half). The /// present handle is still gated by the `tunnel` feature at wave /// 2: absent + unfeatured both decline; present + featured /// serves. pub channel_client: Option>, /// The `-R` listen leg's registration seam ([`TunnelListenSeam`], /// the shared per-connection registry the fabric's inbound /// forwarded opens dispatch against). **Unset → the listen rows /// decline even under the `tunnel` feature with a fabric leg /// present** (the egress-only door is legitimate config, the /// POC-3 stage-C posture; shape decision recorded on the type). #[cfg(feature = "tunnel")] pub tunnel_listen_seam: Option, /// The listen leg's per-arrival bookkeeping, installed by /// [`crate::SshAdapter::new`] when the fabric leg + seam are both /// present (the registration site). Crate-private: never assembly /// data. #[cfg(feature = "tunnel")] pub(crate) listen_router: Option>, /// The bounded-resources budget this serving direction enforces /// (see [`Limits`]). pub limits: Limits, } impl ServeConfig { /// Builds a serve-config from the two injected handles the /// serving direction can never default, the identity provider /// and the host-key source. The OQ-08 fields start unset (their /// rows decline), the tunnel handle starts absent (its rows /// decline), and `limits` starts at [`Limits::default()`], /// override the `pub` fields from the assembly. pub fn new( identity_provider: Arc, host_key_source: Arc, ) -> Self { Self { shell_argv: None, exec_wrapper_argv: None, session_cwd: None, session_env: BTreeMap::new(), #[cfg(feature = "session")] session_seam: None, #[cfg(feature = "git")] git_seam: None, identity_provider, host_key_source, channel_client: None, #[cfg(feature = "tunnel")] tunnel_listen_seam: None, #[cfg(feature = "tunnel")] listen_router: None, limits: Limits::default(), } } /// Whether the `shell_request` row is configurable at all in this /// configuration (`Some` under the `session` feature): `false` /// means the row declines; the unset-declines contract, checked /// at request time by the wave-2 dispatch, never here. pub fn shell_configured(&self) -> bool { self.shell_argv.is_some() } /// Whether the non-git `exec_request` (pipe) row is configurable /// at all in this configuration: `false` means the row declines; /// the unset-declines contract, checked at request time by the /// wave-2 dispatch, never here. pub fn exec_wrapper_configured(&self) -> bool { self.exec_wrapper_argv.is_some() } /// Whether the tunnel rows are configurable at all in this /// configuration (`channel_client` present; the `tunnel` feature /// gate applies at wave 2 on top): `false` means the rows decline. pub fn tunnel_configured(&self) -> bool { self.channel_client.is_some() } /// Whether the `-R` listen rows' registration seam is configured /// in this configuration ([`TunnelListenSeam`] present; the /// `tunnel` feature gate applies at the dispatch on top): `false` /// means the listen rows decline; the unset-declines contract, /// checked at request time by the mappings, never here. #[cfg(feature = "tunnel")] pub fn tunnel_listen_seam_configured(&self) -> bool { self.tunnel_listen_seam.is_some() } /// Whether the session rows' family seam is configured in this /// configuration ([`SessionSeam`] present; the `session` feature /// gate applies at the dispatch on top): `false` means the rows /// decline; the unset-declines contract, checked at request time /// by the wave-3 mappings, never here. #[cfg(feature = "session")] pub fn session_seam_configured(&self) -> bool { self.session_seam.is_some() } /// Whether the git row's family seam is configured in this /// configuration ([`GitSeam`] present; the `git` feature gate /// applies at the dispatch on top): `false` means the row declines; /// the unset-declines contract, checked at request time by the /// wave-3 mappings, never here. #[cfg(feature = "git")] pub fn git_seam_configured(&self) -> bool { self.git_seam.is_some() } } impl std::fmt::Debug for ServeConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut debug = f.debug_struct("ServeConfig"); debug .field("shell_argv", &self.shell_argv) .field("exec_wrapper_argv", &self.exec_wrapper_argv) .field("session_cwd", &self.session_cwd) .field("session_env", &self.session_env) .field("identity_provider", &"") .field("host_key_source", &""); #[cfg(feature = "session")] { debug.field("session_seam", &self.session_seam.is_some()); } #[cfg(feature = "git")] { debug.field("git_seam", &self.git_seam.is_some()); } debug.field("channel_client", &self.channel_client.as_ref().is_some()); #[cfg(feature = "tunnel")] { debug.field("tunnel_listen_seam", &self.tunnel_listen_seam.is_some()); } debug.field("limits", &self.limits).finish() } } /// The client direction's dial configuration (`client.md` §The dial /// surface): everything wave-4's dial surface takes. Pure data + /// injected handles; the russh client `Config` construction and the /// dial itself live in the crate's dial module (the `w4-dial` /// surface). /// /// Construction carries no validation: the key source's own /// load path is fail-closed ([`KeySource`]: locked/absent/decryption /// failures are all no-dial outcomes), and the host-key policy's /// default is fail-closed ([crate::host_key::RejectAll]). /// /// **The `username` field** (the task's sanctioned /// `w3-seam-config`-pattern course-correction, the only amendment the /// wave-1 shape needed): the USERAUTH_REQUEST's user string. The /// wave-1 shape carried everything `client.md` §The dial surface names /// but structurally missed this one: russh's `authenticate_publickey` /// requires a user, and the remote end evaluates it; the family /// identity model stays fingerprint-only forever (`auth.md` §Host-key; /// the username is not identity and carries no routing semantics, /// OQ-03), so this field is pure dial data the assembly supplies. No /// crate default exists (a default account would be deployment policy /// smuggled into the crate); an empty string is accepted as data and /// is the remote end's to refuse. pub struct DialConfig { /// The dial target: `host:port` as russh's connect surface /// takes it (the reverse-runner shape: vanilla `sshd` on the /// fleet box). The same address labels the host-key policy's /// presented-key decisions (host = the parsed host, port = the /// parsed port). pub address: String, /// The user the dial authenticates as, verbatim on the /// USERAUTH_REQUEST, not identity (the fingerprint is; ADR-004 /// decision 5's posture, mirrored client-side), never logged (the /// crate's event vocabulary carries no username). See the type /// docs. pub username: String, /// The key source the dial authenticates with (the wave-1 /// [`KeySource`] seam): never a static key, never env vars; /// decrypt-at-dial, fail-closed per ADR-005. pub key_source: Arc, /// The injected host-key policy (OQ-05), the trust decision /// surface the dial calls at russh's `check_server_key` hook. /// The crate is policy-blind; the built-in default policy /// ([crate::host_key::RejectAll]) connects to nothing, by design. pub host_key_policy: Arc, /// The bounded-resource budget this dial runs under; the /// keepalive interval/max, the wall clock and the window/packet /// knobs ride here (see [`Limits`]; the keepalive 15 s / 3 /// default posture is the client direction's). pub limits: Limits, } impl DialConfig { /// Builds a dial-config from the address, the user it /// authenticates as, and the two injected handles the dial /// direction can never default, the key source and the host-key /// policy. `limits` starts at [`Limits::default()`] (keepalive /// 15 s / 3 posture riding inside). pub fn new( address: impl Into, username: impl Into, key_source: Arc, host_key_policy: Arc, ) -> Self { Self { address: address.into(), username: username.into(), key_source, host_key_policy, limits: Limits::default(), } } } impl std::fmt::Debug for DialConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DialConfig") .field("address", &self.address) .field("username", &"") .field("key_source", &"") .field("host_key_policy", &"") .field("limits", &self.limits) .finish() } } #[cfg(test)] mod tests { use std::sync::Arc; use alkcall::core::auth::NoopIdentityProvider; use async_trait::async_trait; use super::{DEFAULT_INACTIVITY_TIMEOUT, DialConfig, Limits, ServeConfig}; use crate::error::KeySourceError; use crate::host_key::RejectAll; use crate::key_source::{HostKeySource, KeySource}; use russh::keys::PrivateKey; struct NoKeySource; #[async_trait] impl KeySource for NoKeySource { async fn load(&self) -> Result { Err(KeySourceError::Absent) } } struct NoHostKeysSource; #[async_trait] impl HostKeySource for NoHostKeysSource { async fn load(&self) -> Result, KeySourceError> { Err(KeySourceError::Absent) } } fn key_source() -> Arc { Arc::new(NoKeySource) } fn host_key_source() -> Arc { Arc::new(NoHostKeysSource) } #[test] fn limits_default_carries_the_documented_postures() { let limits = Limits::default(); assert_eq!(limits.max_concurrent_channels, 64); assert_eq!(limits.max_exec_command_len, 8192); assert_eq!(limits.max_auth_attempts, 10); assert_eq!(limits.window_size, 2_097_152); assert_eq!(limits.maximum_packet_size, 32_768); assert_eq!(limits.channel_buffer_size, 100); assert_eq!(limits.inactivity_timeout, DEFAULT_INACTIVITY_TIMEOUT); assert_eq!(limits.inactivity_timeout.as_secs(), 600); assert_eq!( limits.keepalive_interval, Some(std::time::Duration::from_secs(15)) ); assert_eq!(limits.keepalive_max, 3); } #[test] fn limits_are_copyable_and_tunable() { let limits = Limits::default(); let copy = limits; assert_eq!(limits, copy); let tuned = Limits { max_concurrent_channels: 8, max_exec_command_len: 1024, inactivity_timeout: std::time::Duration::from_secs(3600), keepalive_interval: None, ..Limits::default() }; assert_eq!(tuned.max_concurrent_channels, 8); assert_eq!(tuned.max_exec_command_len, 1024); assert_eq!(tuned.keepalive_interval, None); } #[test] fn serve_config_starts_unconfigured_everywhere() { let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); assert!(serve.shell_argv.is_none()); assert!(serve.exec_wrapper_argv.is_none()); assert!(!serve.shell_configured()); assert!(!serve.exec_wrapper_configured()); assert!(!serve.tunnel_configured()); assert!(serve.channel_client.is_none()); assert!(serve.session_cwd.is_none()); assert!(serve.session_env.is_empty()); assert_eq!(serve.limits, Limits::default()); } #[test] fn serve_config_option_semantics_flip_independently() { let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); serve.shell_argv = Some(vec!["/bin/sh".into(), "-l".into()]); assert!(serve.shell_configured()); assert!(!serve.exec_wrapper_configured()); assert!(!serve.tunnel_configured()); serve.exec_wrapper_argv = Some(vec!["/bin/sh".into(), "-c".into()]); assert!(serve.shell_configured()); assert!(serve.exec_wrapper_configured()); serve.shell_argv = None; assert!(!serve.shell_configured()); assert!(serve.exec_wrapper_configured()); } #[test] fn serve_config_holds_the_injected_handles() { let provider: Arc = Arc::new(NoopIdentityProvider); let keys = host_key_source(); let serve = ServeConfig::new(Arc::clone(&provider), Arc::clone(&keys)); assert!(Arc::ptr_eq(&provider, &serve.identity_provider)); assert!(Arc::ptr_eq(&keys, &serve.host_key_source)); } #[test] fn serve_config_env_and_cwd_are_assembly_supplied_not_wire_derived() { let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); serve.session_cwd = Some(std::path::PathBuf::from("/srv/git")); serve .session_env .insert("GIT_DIR".to_string(), "/srv/git".to_string()); assert_eq!( serve.session_cwd.as_deref(), Some(std::path::Path::new("/srv/git")) ); assert_eq!( serve.session_env.get("GIT_DIR").map(String::as_str), Some("/srv/git") ); assert_eq!(serve.session_env.len(), 1); } #[cfg(feature = "git")] mod git_seam { use alkgit::{ GitPackGen, GitPackIngest, GitRefs, GitRegistry, PreparedPush, PushOptions, RefLine, RefOutcome, RefUpdate, RegistryError, RepoRecord, StorageError, }; use async_trait::async_trait; use gix_hash::ObjectId; use super::super::{GitSeam, ServeConfig}; use super::*; struct NoopRegistry; #[async_trait] impl GitRegistry for NoopRegistry { async fn resolve(&self, _repo_id: &str) -> Result { Err(RegistryError::NotFound) } } struct NoopRefs; #[async_trait] impl GitRefs for NoopRefs { async fn list_refs(&self, _repo: &RepoRecord) -> Result, StorageError> { Ok(Vec::new()) } async fn apply_updates( &self, _repo: &RepoRecord, _updates: Vec, _atomic: bool, ) -> Result, StorageError> { Ok(Vec::new()) } } struct NoopPackGen; #[async_trait] impl GitPackGen for NoopPackGen { async fn generate( &self, _repo: &RepoRecord, _wants: Vec, _boundary_haves: Vec, _limits: alkgit::Limits, _sink: Box, ) -> Result<(), StorageError> { Ok(()) } async fn common_haves( &self, _repo: &RepoRecord, _haves: Vec, ) -> Result, StorageError> { Ok(Vec::new()) } } struct NoopPackIngest; #[async_trait] impl GitPackIngest for NoopPackIngest { async fn prepare( &self, _repo: &RepoRecord, _updates: Vec, _pack: Box, _push_options: Option, _limits: alkgit::Limits, ) -> Result { Ok(PreparedPush { unpack: Ok(()), updates: Vec::new(), }) } } fn assembled_seam() -> GitSeam { GitSeam { limits: alkgit::Limits::default(), registry: Arc::new(NoopRegistry), refs: Arc::new(NoopRefs), packgen: Arc::new(NoopPackGen), ingest: Arc::new(NoopPackIngest), push_options_negotiated: false, pipeline: Arc::new(tokio::sync::Semaphore::new(2)), } } #[test] fn serve_config_git_seam_unset_declines_though_the_feature_is_on() { let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); assert!(serve.git_seam.is_none()); assert!(!serve.git_seam_configured()); let debug = format!("{serve:?}"); assert!(debug.contains("git_seam: false")); } #[test] fn serve_config_holds_the_git_seam_whole_and_cloneable() { let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); let seam = assembled_seam(); let registry = Arc::clone(&seam.registry); serve.git_seam = Some(seam); assert!(serve.git_seam_configured()); let held = serve.git_seam.as_ref().expect("the seam holds"); assert!(Arc::ptr_eq(®istry, &held.registry)); assert!(!held.push_options_negotiated); assert_eq!(held.pipeline.available_permits(), 2); let cloned = held.clone(); assert!(Arc::ptr_eq(&cloned.registry, ®istry)); assert!(!cloned.push_options_negotiated); assert_eq!(cloned.pipeline.available_permits(), 2); let debug = format!("{serve:?}"); assert!(debug.contains("git_seam: true")); } #[test] fn git_seam_pipeline_is_the_single_server_wide_share() { let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); let pipeline = Arc::new(tokio::sync::Semaphore::new(2)); let mut seam = assembled_seam(); seam.pipeline = Arc::clone(&pipeline); serve.git_seam = Some(seam); let held = serve.git_seam.as_ref().expect("the seam holds"); let cloned = held.clone(); assert!(Arc::ptr_eq(&pipeline, &held.pipeline)); assert!(Arc::ptr_eq(&pipeline, &cloned.pipeline)); let _permit = cloned .pipeline .try_acquire() .expect("a permit is available"); assert_eq!(held.pipeline.available_permits(), 1); } #[test] fn git_seam_carries_the_assembly_supplied_session_budget() { let mut seam = assembled_seam(); seam.limits = alkgit::Limits { max_pack_size: 1 << 20, ..Default::default() }; let cloned = seam.clone(); assert_eq!(cloned.limits, seam.limits); assert_eq!(cloned.limits.max_pack_size, 1 << 20); } #[test] fn git_seam_debug_shapes_itself_not_the_backends() { let seam = assembled_seam(); let debug = format!("{seam:?}"); assert!(debug.contains("GitSeam")); assert!( debug.contains("limits: Limits"), "the ADR-009 budget rides the debug shape verbatim" ); assert!(debug.contains("")); assert!(debug.contains("")); assert!(debug.contains("")); assert!(debug.contains("")); assert!(debug.contains("push_options_negotiated: false")); assert!(debug.contains("pipeline_permits: 2")); } } #[test] fn dial_config_defaults_carry_the_keepalive_posture() { let dial = DialConfig::new( "runner.example:22", "runner", key_source(), Arc::new(RejectAll), ); assert_eq!(dial.address, "runner.example:22"); assert_eq!(dial.limits, Limits::default()); assert_eq!( dial.limits.keepalive_interval, Some(std::time::Duration::from_secs(15)) ); assert_eq!(dial.limits.keepalive_max, 3); assert!(dial.limits.inactivity_timeout > std::time::Duration::ZERO); } #[test] fn dial_config_holds_the_injected_handles() { let keys = key_source(); let policy: Arc = Arc::new(RejectAll); let dial = DialConfig::new( "fleet.example:22", "deploy", Arc::clone(&keys), Arc::clone(&policy), ); assert!(Arc::ptr_eq(&keys, &dial.key_source)); assert!(Arc::ptr_eq(&policy, &dial.host_key_policy)); assert_eq!(dial.address, "fleet.example:22"); assert_eq!(dial.username, "deploy"); } /// an empty string is the assembly's datum (never a crate /// default). #[test] fn dial_config_username_is_data_not_a_crate_default() { let dial = DialConfig::new("fleet.example:22", "", key_source(), Arc::new(RejectAll)); assert_eq!(dial.username, ""); } // The wave-3 per-feature seam handles: the construction shapes, the // unset-declines-preserved rows and the Debug posture; each block // only runs in the build that carries its feature (compile-time // feature isolation: no git build ever touches alktty types, // no session build ever touches alkgit types). #[cfg(feature = "session")] mod session_seam { use alktty::{TtyBackend, TtyError, TtyHandle, TtyParams}; use async_trait::async_trait; use super::super::{ServeConfig, SessionSeam}; use super::*; struct StubBackend; #[async_trait] impl TtyBackend for StubBackend { async fn allocate(&self, _params: &TtyParams) -> Result { Err(TtyError::AllocFailed { message: "stub".into(), }) } } fn assembled_seam() -> SessionSeam { let mut backends = std::collections::HashMap::new(); backends.insert("stub".to_string(), Arc::new(StubBackend) as _); SessionSeam { backends: Arc::new(backends), backend: "stub".to_string(), ownership: None, } } #[test] fn serve_config_session_seam_unset_declines_though_the_feature_is_on() { let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); assert!(serve.session_seam.is_none()); assert!(!serve.session_seam_configured()); let debug = format!("{serve:?}"); assert!(debug.contains("session_seam: false")); } #[test] fn serve_config_holds_the_session_seam_whole_and_cloneable() { let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); let seam = assembled_seam(); let backends = Arc::clone(&seam.backends); serve.session_seam = Some(seam); assert!(serve.session_seam_configured()); let held = serve.session_seam.as_ref().expect("the seam holds"); assert!(Arc::ptr_eq(&backends, &held.backends)); assert_eq!(held.backend, "stub"); assert!(held.ownership.is_none()); let cloned = held.clone(); assert!(Arc::ptr_eq(&cloned.backends, &backends)); assert_eq!(cloned.backend, "stub"); let debug = format!("{serve:?}"); assert!(debug.contains("session_seam: true")); } #[test] fn session_seam_configure_is_one_option_independent_of_the_oq08_fields() { let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); serve.session_seam = Some(assembled_seam()); assert!(serve.session_seam_configured()); assert!(!serve.shell_configured()); assert!(!serve.exec_wrapper_configured()); serve.shell_argv = Some(vec!["/bin/sh".to_string(), "-l".to_string()]); assert!(serve.shell_configured()); assert!(serve.session_seam_configured()); serve.session_seam = None; assert!(serve.shell_configured()); assert!(!serve.session_seam_configured()); } #[test] fn session_seam_debug_shapes_itself_not_the_backends() { let seam = assembled_seam(); let debug = format!("{seam:?}"); assert!(debug.contains("SessionSeam")); assert!(debug.contains(r#"backend_keys: ["stub"]"#)); assert!(debug.contains(r#"backend: "stub""#)); assert!(debug.contains("ownership_set: false")); } } /// - *Contract stamp:* the `w3-tunnel-listen` seam shape (the /// unset-declines contract's tunnel extension): the seam starts /// unset (the listen rows decline; an egress-only door is /// legitimate config), sets independently of the fabric handle, /// and Debug-shapes without registry internals. #[cfg(feature = "tunnel")] #[test] fn serve_config_listen_seam_unset_declines_and_sets_independently() { let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); assert!(serve.tunnel_listen_seam.is_none()); assert!(!serve.tunnel_listen_seam_configured()); assert!(!serve.tunnel_configured()); let debug = format!("{serve:?}"); assert!(debug.contains("tunnel_listen_seam: false")); let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source()); serve.tunnel_listen_seam = Some(super::super::TunnelListenSeam::new(Default::default())); assert!(serve.tunnel_listen_seam_configured()); let debug = format!("{serve:?}"); assert!(debug.contains("tunnel_listen_seam: true")); let seam_debug = format!("{:?}", serve.tunnel_listen_seam.as_ref().expect("holds")); assert!( seam_debug.contains(""), "the seam Debug shapes the registry without internals: {seam_debug}" ); } #[tokio::test] async fn dial_config_key_source_stays_fail_closed_through_the_config() { let dial = DialConfig::new( "fleet.example:22", "deploy", key_source(), Arc::new(RejectAll), ); let err = dial.key_source.load().await.expect_err("no key source"); assert!(matches!(err, KeySourceError::Absent)); let policy = dial.host_key_policy.as_ref(); assert!( !policy .decide(&crate::host_key::PresentedHostKey { host: "fleet.example".into(), port: 22, fingerprint: "SHA256:anything".into(), }) .await ); } }