1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
//! gunnar's **SSH server**: the half of git-over-SSH that does not exist
//! anywhere else in the constellation (the reuse rule).
//!
//! The client half is `nornir-git`, published and proven against codeberg and
//! GitHub. This crate is the other end of the same wire: a [`russh`] server
//! that authenticates a *key*, parses exactly two commands, resolves a
//! repository inside a sandbox, checks a grant, and hands the channel's byte
//! stream to the protocol state machines.
//!
//! ```text
//! TCP :2222
//! └── russh server ── server::GunnarSsh
//! ├── auth_publickey ── access::fingerprint_of_key
//! │ → gunnar_core::AuthTable (control::Control)
//! ├── exec_request ── command::parse → command::unquote
//! │ → gunnar_core::resolve_repo_path
//! │ → gunnar_core::RepoCatalog → grant check
//! └── channel stream ── seam::GitTransport
//! ```
//!
//! One deliberate asymmetry in that resolve: a `git-receive-pack` for a
//! repository that does not exist, from a principal the control plane already
//! lets create repositories there, creates it — the **first-repository door**
//! that makes an installed `--no-grpc` appliance administrable with nothing
//! but the admin key and stock git. See [`Control::create_for_push`] for the
//! whole argument; for every other key the not-found refusal is unchanged.
//!
//! # What this crate deliberately does NOT do
//!
//! * **No shell. Ever.** `shell_request`, `subsystem_request`, `pty_request`
//! and `x11_request` are all answered with a channel failure (see
//! [`server`]). The only thing that can be executed is one of the verbs in
//! [`command::SERVICES`], and there is no `exec` path to a process at all:
//! the "command" selects an in-process state machine.
//! * **No environment, bar one allowlisted name.** `env_request` accepts
//! `GIT_PROTOCOL` and refuses every other variable
//! ([`server::ENV_ALLOWLIST`]). It is how a client asks for protocol v2, and
//! it is the only thing a client can set that the server remembers. Nothing
//! is exported to a process — there is no process — and it is attached to
//! [`ServiceRequest`] *after* the grant check, so it can never reach an
//! authorisation decision.
//! * **No filesystem.** [`gunnar_core::resolve_repo_path`] parses the exec
//! argument into a validated namespace and name, never into a `Path`. There
//! is no path traversal because there is no path. That is the strongest
//! available form of the ACL sandbox, and a fix by construction rather than a
//! blocklist (one implementation).
//! * **No `ssh-keygen`.** The host key is minted in-process and persisted
//! through [`gunnar_store::SecretStore`]. See
//! [`gunnar_core::host_key::ensure_host_key`].
//! * **No OS users.** The SSH *username* is decorative. `git@host:ns/repo.git`
//! works and `git` means nothing; identity comes from the offered key's
//! SHA-256 fingerprint (the ACL model).
//! * **No unbounded concurrency, and no invisible one.** [`admission`] caps how
//! many git transfers exist at once, server-wide and per principal, and
//! *refuses* over the limit with a message the client can read and a counter
//! an operator can see ([`AdmissionStats`]). Before it, the ceiling was
//! `tokio`'s default of 512 blocking threads, a number nobody chose, and past
//! it transfers queued in silence.
//! * **No unattributable line.** Every connection opens a `ssh.connection`
//! span carrying a [`RequestId`], the peer, and — as they become known — the
//! principal and the repository ([`server`]). A transfer runs in a
//! `git.transfer` span beneath it, and `gunnar-cli`'s transport and
//! `gunnar-policy`'s gate emit *underneath that*, so a whole clone is
//! greppable by `conn=<id>` and no call site repeats a field the span already
//! carries (one implementation). [`ServerMetrics`] holds the counters; it is one
//! block of atomics, shared with the transport, and no lock is taken to
//! record one.
//! * **No second copy of the security model.** See below.
//!
//! # ⚠ ONE sandbox, ONE fingerprint
//!
//! This crate and `gunnar-core` were written concurrently and **each grew its
//! own** path sandbox, key fingerprint, host-key loader and auth lookup. That
//! is the twinning one implementation forbids, and it was worse than it looks: the two
//! sandboxes **disagreed**. `gunnar_ssh::path` enforced exactly two path
//! components; `gunnar_core::sandbox` permits nested namespaces. Two security
//! checks that disagree are worse than either alone, because the strict one
//! gives false confidence about the permissive one.
//!
//! This crate now depends on `gunnar-core` and its copies are **deleted**:
//!
//! | was here | is now |
//! |---|---|
//! | `path::RepoPath` | [`gunnar_core::resolve_repo_path`] → [`gunnar_core::RepoRef`] |
//! | `access::KeyFingerprint` | [`gunnar_core::fingerprint_of_blob`] → [`gunnar_core::Fingerprint`] |
//! | `hostkey::load_or_create_host_key` | [`gunnar_core::host_key::ensure_host_key`] |
//! | `seam::AuthDirectory`, `seam::RepoResolver` | [`gunnar_core::AuthTable`], [`gunnar_core::RepoCatalog`], behind [`control::Control`] |
//! | `access::{Access, Principal, PrincipalKind}` | re-exports of `gunnar_core::{Level, Principal, PrincipalKind}` |
//!
//! **The permissive shape is the surviving one**: nested namespaces
//! are legal (`team/sub/repo.git` → namespace `team/sub`), and the escalation
//! that would otherwise carry is closed by grants **not** inheriting: `team`
//! does not imply `team/sub`. That rule is what makes the merge safe, and it is
//! pinned by `gunnar-core`'s `tests/acl.rs`.
//!
//! **What stayed here:** [`command::unquote`]. git sends one argv element with
//! its quotes inside it and gunnar has no shell to strip them, but that is a
//! transport fact; the sandbox is handed a bare string and does not know SSH
//! exists (the one-sandbox rule).
//!
//! # ⚠ SEAMS: what is stubbed, and how to wire it
//!
//! One is left. `gunnar-wire` (the protocol state machines of the git wire protocol) is still
//! being written, so [`seam::GitTransport`] describes exactly what an SSH front
//! end needs from it and nothing more. It is a **seam, not a model**, and must
//! not become a second source of truth for anything `gunnar-core` owns (one implementation).
//! [`seam::BannerTransport`] is a test double, not a backend.
//!
//! # ⚠ A dependency fact that contradicts the spec's dependency table
//!
//! The dependency budget lists `russh` **and** `ssh-key` as server dependencies, and the
//! workspace pins `ssh-key = "0.6"`. **russh 0.61.2 pins
//! `ssh-key = "=0.7.0-rc.10"`**, an exact, semver-incompatible requirement.
//! `russh::server::Handler::auth_publickey` takes `&ssh_key::PublicKey` from
//! *that* copy, so the workspace's `ssh-key 0.6` types can never be handed to
//! russh. This crate therefore names no `ssh-key` dependency of its own and
//! uses [`russh::keys::ssh_key`] throughout.
//!
//! Depending on `gunnar-core` does **not** change that: both copies are in the
//! graph, as they always were, and nothing here ever names an `ssh_key 0.6`
//! type. The fingerprint crosses the boundary as **bytes**:
//! [`access::fingerprint_of_key`] takes russh's key, calls `to_bytes()`, and
//! hands the wire blob to [`gunnar_core::fingerprint_of_blob`]. The workspace was
//! deliberately not bumped to a release candidate to paper over the split.
//!
//! **Since the RustCrypto bump (2026-10-03)** the workspace DOES pin the release
//! candidate russh 0.63 rides (`ssh-key = "=0.7.0-rc.11"`, an exact pin until a
//! stable 0.7 ships), so there is one copy and one `rand_core` (0.10); the
//! twenty-line rand_core bridge this crate carried (`rng`) is gone and keys are
//! minted from [`gunnar_core::os_rng`]. The fingerprint still crosses as bytes,
//! so the next russh that moves its pin splits nothing.
//!
//! # ⚠ russh REQUIRES a C crypto backend, so the dependency budget cannot hold as written
//!
//! The dependency budget forbids "any C toolchain requirement" anywhere in the build graph.
//! **russh 0.61.2 cannot satisfy that.** `russh/src/lib.rs:90` is a
//! `compile_error!` reading *"`russh` requires enabling either the `ring` or
//! `aws-lc-rs` feature as a crypto backend"*, and both of those are C/assembly
//! libraries with a build script. With russh's default features the graph
//! carries `aws-lc-rs` → `aws-lc-sys`, which builds C with `cmake`.
//!
//! Measured 2026-08-03 on this crate's `-e normal` tree: **196 distinct crates**,
//! of which `aws-lc-rs` and `aws-lc-sys` are the C ones. There is no rayon, no
//! openssl, no `git2`.
//!
//! This cannot be fixed from inside `gunnar-ssh`, because a member crate may not
//! turn off a workspace dependency's default features. The choice is the
//! workspace's, and it is a real choice with no clean option:
//!
//! * `russh = { version = "0.61", default-features = false, features = ["ring", "flate2"] }`
//! swaps `aws-lc-rs` for `ring`, which is smaller but still C and assembly,
//! * keep `aws-lc-rs` and amend the dependency budget to say "no *unvendored* C", since both
//! backends vendor their sources and need no system library,
//! * drop russh, which would mean writing an SSH transport, and `nornir-git`
//! uses russh on the client side already (the reuse rule), so it would have to go too.
//!
//! Recorded here rather than decided here.
pub use ;
pub use ;
pub use ;
pub use ;
pub use ;
/// The shared admission trait the gate asks (`admit-share`) and
/// `Usage.AdmitPlan` reads — re-exported so the server names one version of it.
pub use edda_admit;
pub use ;
pub use ;
pub use ;
pub use ;
pub use ;
pub use ;
pub use ;
// The one sandbox, the one host key. Re-exported so a caller does not
// have to know which crate they came from, but there is no second definition
// here; these ARE `gunnar-core`'s.
pub use ;
pub use ;
pub use ;
// Re-exported so a downstream crate can name the key types russh hands over
// without having to work out for itself which `ssh-key` russh actually links
// (see the note above; it is not the workspace one).
pub use russh;
pub use ssh_key;