Same treatment for the prose the compiler carries: doc and line comments across src/ and tests/, test assertion and error-message strings (prose surfaces: the tunnel-map registration/establish errors, the key-source decode payload, assertion messages), scripts/matrix.sh comment lines, the fuzz sidecar's comments and README (corpus seeds untouched), and the Cargo.toml comment headers plus the crates.io description. The AGENTS.md no-em-dash rule (convention 14) now holds for the whole tracked tree. Verification: repo-wide git grep is clean (single exempt hit: the verification command's own search pattern), cargo test (18 suites, zero failures, default and per-feature builds via scripts/matrix.sh), clippy --all-targets -D warnings and clippy all-features, fmt --check, doc --no-deps, and the fuzz corpus gate (fuzz/shared, 11 tests) all green.
1186 lines
49 KiB
Rust
1186 lines
49 KiB
Rust
//! 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<Arc<ChannelClient>>`, 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<dyn ..>` 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<inactivity_timeout>` 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<Duration>,
|
|
/// 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<Duration> = 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<HashMap<String, Arc<dyn TtyBackend>>>,
|
|
/// 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<Arc<dyn OwnershipProvider>>,
|
|
}
|
|
|
|
#[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<dyn GitRegistry>,
|
|
/// The ref listing + CAS transaction trait
|
|
/// (`list_refs`/`apply_updates`).
|
|
pub refs: Arc<dyn GitRefs>,
|
|
/// The pack generation + negotiation-ack trait
|
|
/// (`generate`/`common_haves`).
|
|
pub packgen: Arc<dyn GitPackGen>,
|
|
/// The pack-preparation trait (`prepare` never applies; the
|
|
/// `GitRefs::apply_updates` CAS stays the single ingestion home).
|
|
pub ingest: Arc<dyn GitPackIngest>,
|
|
/// 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<tokio::sync::Semaphore>,
|
|
}
|
|
|
|
#[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", &"<dyn GitRegistry>")
|
|
.field("refs", &"<dyn GitRefs>")
|
|
.field("packgen", &"<dyn GitPackGen>")
|
|
.field("ingest", &"<dyn GitPackIngest>")
|
|
.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<ChannelClient>` 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<OperationRegistry>,
|
|
}
|
|
|
|
/// 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<OperationRegistry>) -> 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", &"<shared OperationRegistry>")
|
|
.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<Vec<String>>,
|
|
/// 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<Vec<String>>,
|
|
/// 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<PathBuf>,
|
|
/// The OQ-09 posture's assembly-supplied environment for
|
|
/// `NegotiateRequest.env`, **never wire-derived**. Empty by
|
|
/// default.
|
|
pub session_env: BTreeMap<String, String>,
|
|
/// 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<SessionSeam>,
|
|
/// 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<GitSeam>,
|
|
/// 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<dyn IdentityProvider>,
|
|
/// 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<dyn HostKeySource>,
|
|
/// 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<Arc<ChannelClient>>,
|
|
/// 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<TunnelListenSeam>,
|
|
/// 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<Arc<ListenRouter>>,
|
|
/// 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<dyn IdentityProvider>,
|
|
host_key_source: Arc<dyn HostKeySource>,
|
|
) -> 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", &"<dyn IdentityProvider>")
|
|
.field("host_key_source", &"<dyn HostKeySource>");
|
|
#[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<dyn KeySource>,
|
|
/// 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<dyn HostKeyPolicy>,
|
|
/// 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<String>,
|
|
username: impl Into<String>,
|
|
key_source: Arc<dyn KeySource>,
|
|
host_key_policy: Arc<dyn HostKeyPolicy>,
|
|
) -> 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", &"<present>")
|
|
.field("key_source", &"<dyn KeySource>")
|
|
.field("host_key_policy", &"<dyn HostKeyPolicy>")
|
|
.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<PrivateKey, KeySourceError> {
|
|
Err(KeySourceError::Absent)
|
|
}
|
|
}
|
|
|
|
struct NoHostKeysSource;
|
|
|
|
#[async_trait]
|
|
impl HostKeySource for NoHostKeysSource {
|
|
async fn load(&self) -> Result<Vec<PrivateKey>, KeySourceError> {
|
|
Err(KeySourceError::Absent)
|
|
}
|
|
}
|
|
|
|
fn key_source() -> Arc<dyn KeySource> {
|
|
Arc::new(NoKeySource)
|
|
}
|
|
|
|
fn host_key_source() -> Arc<dyn HostKeySource> {
|
|
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<dyn alkcall::core::auth::IdentityProvider> =
|
|
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<RepoRecord, RegistryError> {
|
|
Err(RegistryError::NotFound)
|
|
}
|
|
}
|
|
|
|
struct NoopRefs;
|
|
|
|
#[async_trait]
|
|
impl GitRefs for NoopRefs {
|
|
async fn list_refs(&self, _repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn apply_updates(
|
|
&self,
|
|
_repo: &RepoRecord,
|
|
_updates: Vec<RefUpdate>,
|
|
_atomic: bool,
|
|
) -> Result<Vec<RefOutcome>, StorageError> {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
struct NoopPackGen;
|
|
|
|
#[async_trait]
|
|
impl GitPackGen for NoopPackGen {
|
|
async fn generate(
|
|
&self,
|
|
_repo: &RepoRecord,
|
|
_wants: Vec<ObjectId>,
|
|
_boundary_haves: Vec<ObjectId>,
|
|
_limits: alkgit::Limits,
|
|
_sink: Box<dyn std::io::Write + Send>,
|
|
) -> Result<(), StorageError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn common_haves(
|
|
&self,
|
|
_repo: &RepoRecord,
|
|
_haves: Vec<ObjectId>,
|
|
) -> Result<Vec<ObjectId>, StorageError> {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
struct NoopPackIngest;
|
|
|
|
#[async_trait]
|
|
impl GitPackIngest for NoopPackIngest {
|
|
async fn prepare(
|
|
&self,
|
|
_repo: &RepoRecord,
|
|
_updates: Vec<RefUpdate>,
|
|
_pack: Box<dyn std::io::Read + Send>,
|
|
_push_options: Option<PushOptions>,
|
|
_limits: alkgit::Limits,
|
|
) -> Result<PreparedPush, StorageError> {
|
|
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("<dyn GitRegistry>"));
|
|
assert!(debug.contains("<dyn GitRefs>"));
|
|
assert!(debug.contains("<dyn GitPackGen>"));
|
|
assert!(debug.contains("<dyn GitPackIngest>"));
|
|
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<dyn crate::host_key::HostKeyPolicy> = 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<TtyHandle, TtyError> {
|
|
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("<shared OperationRegistry>"),
|
|
"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
|
|
);
|
|
}
|
|
}
|