feat(git): land the git mapping — the exec grammar + the alkgit dispatch (w3-git-mapping)

- dispatch: the full exec grammar replaces the wave-2 skeleton probe
  (quoted/unquoted paths, the .git normalization decision, unknown-
  service / service-only decline-with-reason class; the probe's unit
  rows migrate with their pinned verdicts preserved)
- git_map: the git row dispatches serve_duplex_resolved over the
  channel duplex (repo id validated, never resolved; the exit-status
  mapping 0/1 decided per POC-3 §A.5; status before close from the
  spawned task), with the resolved identity riding the seam
- handler: the store-and-take channel handoff (stored at the open,
  taken in the spawned serving task, released at close) and the
  max_exec_command_len pre-parse cap
- config course correction: GitSeam carries the assembly-supplied
  alkgit Limits (the ADR-009 budget alkgit's contract assigns the
  assembler)
- tests: tests/git_mapping.rs (byte-level process-mode advertisement,
  ls-refs round + exit 0, the collapsed-denial byte-order + exit 1,
  the identity-ride authorize-gate witness, the packless CAS push,
  the grammar/cap decline rows, the sequential-serve store row, and
  the real-tool git 2.43 clone smoke over a TCP mount with a
  skip-with-reason posture); dispatch_core/observe rows updated to
  the new decision set; tests/common/git_doubles.rs (the hand-rolled
  recording alkgit quartet)

Verification: cargo test (default), --features session, --features
git, --all-features; clippy -D warnings ×2; fmt --check;
cargo doc --no-deps; taskgraph validate
This commit is contained in:
glm-5.3-flash committed 2026-10-28 20:03:10 +00:00
1 parent 58d0b86ec5
commit 7aac2d11a2
12 files changed
+2012 -173

No files matched your search

+33 -3
View File
@@ -239,15 +239,26 @@ impl std::fmt::Debug for SessionSeam {
/// 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 four backend
/// traits, the caller-owned pipeline, the push-options gate). Same
/// whole-`Option`-struct decision as [`SessionSeam`]:
/// (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).
@@ -276,6 +287,7 @@ pub struct GitSeam {
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>")
@@ -751,6 +763,7 @@ mod tests {
fn assembled_seam() -> GitSeam {
GitSeam {
limits: alkgit::Limits::default(),
registry: Arc::new(NoopRegistry),
refs: Arc::new(NoopRefs),
packgen: Arc::new(NoopPackGen),
@@ -815,12 +828,29 @@ mod tests {
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>"));
+294 -83
View File
@@ -1,80 +1,205 @@
//! The parse-only dispatch core (`w2-dispatch-decline-core`) — the
//! routing decision and the wire-decline reason shaping the
//! per-connection handler hooks speak ([`crate::handler`]).
//! The exec-grammar dispatch core (`w2-dispatch-decline-core`, full
//! grammar `w3-git-mapping`) — the routing decision and the wire-decline
//! reason shaping the per-connection handler hooks speak
//! ([`crate::handler`]).
//!
//! **Parse-only** (ADR-006 point 3 as amended): an `exec_request`
//! parses *first* — a git-parseable command routes to the git row,
//! anything else routes to the session pipe row; `pty_request` /
//! `shell_request` route to the session rows. Parsing happens before
//! dispatch and before any resource allocation — **decline first,
//! alloc never** (a leaked channel alloc is a capability leak, the
//! POC-3 stage-A rule). There is no request-less serving mode: a
//! session channel that never receives a request is never served (the
//! SOCKS-detect retired with `dynamic-forward`, ADR-003 §1a), so a
//! request-less session channel declines as unfeatured by
//! construction.
//! parses *first* — before dispatch and before any resource
//! allocation (**decline first, alloc never**, the POC-3 stage-A rule:
//! a leaked channel alloc is a capability leak). The parse is total:
//! it routes every input to exactly one of [`ExecRoute`]'s outcomes.
//! The *per-length limit* ([`crate::Limits::max_exec_command_len`]) is
//! consumed **before** this parse — the handler checks the wire byte
//! length first (pre-parse, pre-allocation), so everything below
//! assumes a sized input. Invalid UTF-8 parses to the pipe row
//! (parse-then-fail, the wave-2 rule).
//!
//! ## The skeleton exec parse probe
//! ## The exec grammar (`w3-git-mapping` — the wave-2 probe's named
//! replacement, its unit rows migrated)
//!
//! [`classify_exec`] is deliberately **skeleton-level** (service
//! prefix + path shape): it recognizes the two serving git services
//! (`git-upload-pack`, `git-receive-pack`) followed by a space and a
//! path-shaped argument (leading `'` — the vanilla-git quoted shape —
//! or `/` — unquoted absolute). Everything else routes to the session
//! pipe row, which in every build that lacks the `session` feature
//! declines: the POC-3 stage-A contract (unknown repo → collapsed
//! generic denial before any protocol byte; non-git command →
//! parse-then-fail). The full grammar (quoted/unquoted paths,
//! per-length limits from [`crate::Limits::max_exec_command_len`],
//! service-only → decline-with-reason, `git-send-pack` /
//! `git-upload-archive` → unknown-service decline) is wave-3's git
//! task, which replaces this probe wholesale — the [`ExecRoute`] rows
//! and the routing decisions around them are what it must keep.
//! What vanilla git sends over SSH (`git-upload-pack '/owner/demo'` —
//! single-quoted repo path with a leading `/`; POC-3 stage A §2's
//! verified grammar table):
//!
//! - **`git-{upload,receive}-pack '<path>'`** → [`ExecRoute::Git`]:
//! the two serving services of ADR-016, one separating space, and a
//! path-shaped argument — quoted (the vanilla wire shape, fully
//! single-quote-wrapped) or unquoted absolute (leading `/`). The
//! path is normalized: quotes stripped, leading `/` stripped, and
//! **one trailing `.git` suffix stripped** — the vanilla-clone idiom
//! (real git sends the remote URL's path verbatim, bare clones carry
//! `.git`; the alkgit registry grammar (`validate_repo_id`) excludes
//! `.`, so the door normalizes the idiom rather than asking
//! registries for dotted keys; an empty remainder keeps `.git` and
//! fails validation — never silently emptied). The normalized path
//! is *not yet validated* — the git row (feature-gated) validates it
//! against `alkgit::validate_repo_id` (1–2 segments, charset, ≤ 100
//! bytes) and hands the id through; **alkssh never resolves** (the
//! no-existence-oracle posture lives in alkgit's collapsed generic
//! denial, and an id failing the *shape* gate is the git row's
//! `invalid-repo-id` decline — see below). Single-segment or
//! `.git`-empty shapes ride the same grammar.
//! - **`git-send-pack` / `git-upload-archive`** →
//! [`ExecRoute::Declined`]([`ExecDecline::UnknownService`]): the
//! known non-serving git services take the honest decline-with-reason
//! surface (POC-3 §2; ADR-016's fixed refusal — `git-upload-archive`
//! is absent from serving by ADR-016 §1). Decision recorded
//! (`w3-git-mapping`): they do **not** ride the pipe row; the decline
//! is build-independent (no feature ever serves them).
//! - **A serving service with no argument at all** (service-only) →
//! [`ExecRoute::Declined`]([`ExecDecline::ServiceOnly`]): SSH exec
//! must carry the repo (POC-3 §A — a subsystem-style name cannot
//! dispatch, so exec without a repo cannot either).
//! - **Everything else** → [`ExecRoute::Pipe`] (parse-then-fail): not
//! git — including path-shaped-but-not-absolute unquoted arguments
//! (`git-upload-pack rel`, exact strings the wave-2 probe pinned)
//! and unterminated quotes; the probe's routing verdicts are
//! preserved verbatim.
//!
//! ## The wire vocabulary (decision, recorded)
//!
//! SSH request replies carry no failure text, so — except for the open
//! level — the *reason* lives in the event vocabulary
//! ([`crate::observe`]), not on the wire. Decision: the grammar
//! declinables (`unknown-service`, `service-only`, and the git row's
//! `invalid-repo-id`) share the **request-level reply shape** with
//! parse-then-fail — `channel_failure`, channel left usable (the
//! POC-1 §5 session-survives-declines posture; POC-3's decline shape),
//! distinguishable only in the events. The *serving-path* shape
//! (failure + close, the spawned task's) stays reserved for declined
//! rows that owned the channel: the seam-unconfigured git row and the
//! real serve's own outcomes (the denial band alkgit writes before any
//! protocol byte, then exit-status + close — the wire shapes the
//! `git_map` module pins).
use russh::ChannelOpenFailure;
/// Which dispatch row an `exec_request` command routes to (the
/// parse-only decision, ADR-006 point 3 as amended).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// parse-only decision, ADR-006 point 3 as amended; the wave-2
/// skeleton probe's `ExecRoute::Git`/`Pipe` split, extended by the
/// `w3-git-mapping` grammar with the declined-with-reason class).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ExecRoute {
/// A git-parseable command — routes to the git row (the `git`
/// feature gate is the row's other half; the parse is the session
/// build's gate — a session-only build declines here).
Git,
/// A git-parseable command — routes to the git row. The repo path
/// is the normalized argument grammar (quotes, leading `/`, one
/// trailing `.git`); validation (`alkgit::validate_repo_id`) is
/// the git row's gate ([`crate::git_map`]).
Git {
service: GitServiceRoute,
repo: String,
},
/// Everything else — routes to the session pipe row (the
/// `session` feature gate is the row's other half; unfeatured
/// builds decline here).
Pipe,
/// A command the grammar recognizes as git-transport-shaped but
/// that can never dispatch on this door — declined with a reason,
/// honestly and build-independently (see the grammar section).
Declined(ExecDecline),
}
/// The skeleton exec parse probe — see the module docs for the exact
/// shape it recognizes and what wave 3 replaces it with.
pub(crate) fn classify_exec(command: &str) -> ExecRoute {
if is_serving_git_command(command) {
ExecRoute::Git
} else {
ExecRoute::Pipe
}
/// The git service the exec grammar recognized — the ADR-016 wire
/// strings, as the door-side form of [`alkgit::Service`] (the seam maps
/// them verbatim; this type keeps the parse core feature-free).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GitServiceRoute {
/// `git-upload-pack` — fetch.
UploadPack,
/// `git-receive-pack` — push.
ReceivePack,
}
/// The skeleton probe's git recognition: exact serving-service prefix,
/// single separating space, path-shaped argument.
fn is_serving_git_command(command: &str) -> bool {
for service in ["git-upload-pack ", "git-receive-pack "] {
if let Some(argument) = command.strip_prefix(service)
&& is_path_shaped(argument)
{
return true;
/// A git-shaped command that can never dispatch — the decline reasons
/// the grammar itself decides (the git row adds `invalid-repo-id` at
/// the validation gate; that one needs `alkgit`'s grammar, this type
/// is the parse core's).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExecDecline {
/// A known non-serving git service arrived on the wire:
/// `git-send-pack` / `git-upload-archive` (POC-3 §2 — the honest
/// surface; ADR-016's refusal).
UnknownService { service: &'static str },
/// A serving service name with no repo argument at all (POC-3
/// §A: SSH exec must carry the repo).
ServiceOnly,
}
impl ExecDecline {
/// The event-vocabulary reason for this decline (§the wire
/// vocabulary — `&'static str` values only; no wire-carried
/// service token ever rides an event).
pub(crate) fn reason(self) -> &'static str {
match self {
ExecDecline::UnknownService { .. } => "unknown-service",
ExecDecline::ServiceOnly => "service-only",
}
}
false
}
/// The path shape the skeleton probe accepts: a path-shaped argument
/// starts with `'` (quoted — the vanilla-git wire shape) or `/`
/// (unquoted absolute).
fn is_path_shaped(argument: &str) -> bool {
argument.starts_with('\'') || argument.starts_with('/')
/// The exec-grammar parse — the wave-2 [`classify_exec`] probe's
/// replacement, its unit rows migrated (see the grammar section for
/// the exact shape rules).
pub(crate) fn parse_exec(command: &str) -> ExecRoute {
let (token, argument) = match command.split_once(' ') {
Some((token, argument)) => (token, Some(argument)),
None => (command, None),
};
let service = match token {
"git-upload-pack" => GitServiceRoute::UploadPack,
"git-receive-pack" => GitServiceRoute::ReceivePack,
"git-send-pack" => {
return ExecRoute::Declined(ExecDecline::UnknownService {
service: "git-send-pack",
});
}
"git-upload-archive" => {
return ExecRoute::Declined(ExecDecline::UnknownService {
service: "git-upload-archive",
});
}
_ => return ExecRoute::Pipe,
};
let Some(argument) = argument else {
return ExecRoute::Declined(ExecDecline::ServiceOnly);
};
if argument.is_empty() {
return ExecRoute::Declined(ExecDecline::ServiceOnly);
}
let Some(normalized) = normalize_repo_path(argument) else {
return ExecRoute::Pipe;
};
ExecRoute::Git {
service,
repo: normalized,
}
}
/// The argument-grammar stage: a path-shaped argument (fully wrapped
/// in single quotes — the vanilla wire shape — or unquoted absolute;
/// anything else is not git) normalized to the repo path: quotes
/// stripped, leading `/` stripped, one trailing `.git` stripped (the
/// recorded normalization — see the module grammar section; an
/// empty-after-normalized id is *kept* for the git row's validation
/// to decline as `invalid-repo-id`, never silently emptied).
fn normalize_repo_path(argument: &str) -> Option<String> {
let quoted = argument.starts_with('\'');
let stripped = if quoted {
let inner = argument.strip_prefix('\'')?;
if inner.is_empty() || !inner.ends_with('\'') {
return None;
}
&inner[..inner.len() - 1]
} else if argument.starts_with('/') {
argument
} else {
return None;
};
let without_slash = stripped.strip_prefix('/').unwrap_or(stripped);
let normalized = match without_slash.strip_suffix(".git") {
Some(prefix) if !prefix.is_empty() => prefix,
_ => without_slash,
};
Some(normalized.to_owned())
}
/// The wire decline reason for a **featureless** row — a known channel
@@ -113,69 +238,155 @@ pub(crate) fn cap_exceeded_open_decline() -> ChannelOpenFailure {
#[cfg(test)]
mod tests {
use super::{ExecRoute, classify_exec};
use super::{ExecDecline, ExecRoute, GitServiceRoute, parse_exec};
fn route(command: &str) -> bool {
classify_exec(command) == ExecRoute::Git
fn route(command: &str) -> ExecRoute {
parse_exec(command)
}
fn git(command: &str) -> (GitServiceRoute, String) {
let ExecRoute::Git { service, repo } = route(command) else {
panic!("{command:?} must parse as git");
};
(service, repo)
}
fn git_repo(command: &str) -> String {
git(command).1
}
/// - *Contract stamp:* ADR-006 decision 2 (the exec-only git parse
/// — the vanilla wire shape `git-upload-pack '/owner/demo'`).
#[test]
fn vanilla_quoted_upload_pack_parse_is_git() {
assert!(route("git-upload-pack '/owner/demo'"));
let (service, repo) = git("git-upload-pack '/owner/demo'");
assert_eq!(service, GitServiceRoute::UploadPack);
assert_eq!(repo, "owner/demo");
}
/// - *Contract stamp:* ADR-006 decision 2 (the receive-pack half
/// of the exec-only git parse).
#[test]
fn receive_pack_parse_is_git() {
assert!(route("git-receive-pack '/owner/demo'"));
let (service, repo) = git("git-receive-pack '/owner/demo'");
assert_eq!(service, GitServiceRoute::ReceivePack);
assert_eq!(repo, "owner/demo");
}
/// - *Contract stamp:* POC-3 §2 (the unquoted-absolute row of the
/// grammar table: `git-upload-pack /r`).
#[test]
fn unquoted_absolute_pack_parse_is_git() {
assert!(route("git-upload-pack /r"));
assert_eq!(git_repo("git-upload-pack /r"), "r");
}
/// - *Contract stamp:* the recorded `.git` normalization decision
/// (`w3-git-mapping`): one trailing suffix off the vanilla-clone
/// idiom, before the git row's validation.
#[test]
fn trailing_git_suffix_normalizes_off() {
assert_eq!(git_repo("git-upload-pack '/owner/demo.git'"), "owner/demo");
assert_eq!(git_repo("git-upload-pack /r.git"), "r");
assert_eq!(
git_repo("git-upload-pack '/r.git.git'"),
"r.git",
"one suffix only — the remainder fails validation honestly"
);
assert_eq!(
git_repo("git-upload-pack '/.git'"),
".git",
"the empty remainder is kept — the git row declines it, never a silent emptying"
);
}
/// - *Contract stamp:* POC-3 §2 (service-only → declined — SSH
/// exec must carry the repo; the skeleton routes it to the pipe
/// row, which declines in every git-less build; wave 3 gives the
/// decline its own reason).
/// exec must carry the repo; the decline-with-reason surface,
/// not the pipe row).
#[test]
fn service_only_parse_is_not_git() {
assert!(!route("git-upload-pack"));
assert!(!route("git-receive-pack "));
fn service_only_parse_declines() {
assert_eq!(
route("git-upload-pack"),
ExecRoute::Declined(ExecDecline::ServiceOnly)
);
assert_eq!(
route("git-receive-pack "),
ExecRoute::Declined(ExecDecline::ServiceOnly)
);
}
/// - *Contract stamp:* POC-3 §2 (unknown service → declined
/// honestly; the skeleton routes it off the git row).
/// - *Contract stamp:* POC-3 §2 (unknown services declined
/// honestly — the decision that they take the decline surface,
/// not the pipe row).
#[test]
fn unknown_services_parse_is_not_git() {
assert!(!route("git-send-pack '/r'"));
assert!(!route("git-upload-archive '/r'"));
fn unknown_services_parse_to_the_decline() {
assert_eq!(
route("git-send-pack '/r'"),
ExecRoute::Declined(ExecDecline::UnknownService {
service: "git-send-pack"
})
);
assert_eq!(
route("git-upload-archive '/r'"),
ExecRoute::Declined(ExecDecline::UnknownService {
service: "git-upload-archive"
})
);
}
/// - *Contract stamp:* mapping.md §Session-channel dispatch
/// (non-git → parse-then-fail, the pipe row).
/// (non-git → parse-then-fail, the pipe row — the wave-2 probe's
/// pinned routing verdicts, preserved).
#[test]
fn non_git_commands_parse_to_the_pipe_row() {
assert!(!route("uptime"));
assert!(!route(""));
assert!(!route("git-upload-packrel"));
assert!(!route("git-upload-pack rel"));
assert!(!route("GIT-UPLOAD-PACK '/r'"));
assert!(!route("git-upload-pack$ '/r'"));
assert!(!route("git-upload-pack '/r'"));
for command in [
"uptime",
"",
"git-upload-packrel",
"git-upload-pack rel",
"GIT-UPLOAD-PACK '/r'",
"git-upload-pack$ '/r'",
"git-upload-pack '/r'",
"git-upload-pack'r'",
] {
assert_eq!(route(command), ExecRoute::Pipe, "{command:?}");
}
}
/// Unterminated empty quotes are not the vanilla path shape — the
/// probe's pipe verdict applies. A quoted path without the leading
/// slash is still path-shape (the POC's loose quote trim); it rides
/// the grammar to the git row's validation.
#[test]
fn unterminated_quotes_parse_to_the_pipe_row() {
assert_eq!(route("git-upload-pack '/r"), ExecRoute::Pipe);
assert_eq!(route("git-upload-pack '"), ExecRoute::Pipe);
assert_eq!(git_repo("git-upload-pack 'r'"), "r");
}
/// Quoted-empty and slash-only arguments parse as git with an empty
/// normalized id — the git row's validation declines them as
/// `invalid-repo-id` (kept, never silently emptied).
#[test]
fn empty_ids_parse_git_and_the_row_downstream_declines() {
assert_eq!(git_repo("git-upload-pack ''"), "");
assert_eq!(git_repo("git-upload-pack '/'"), "");
}
/// - *Contract stamp:* mapping.md §Session-channel dispatch (deep
/// paths ride the grammar to the git row — validation is the
/// registry-grammar gate downstream, e.g. 3 segments decline as
/// `invalid-repo-id`, not at the parse).
#[test]
fn multi_segment_and_deep_paths_parse_git() {
assert_eq!(git_repo("git-upload-pack '/a/b/c'"), "a/b/c");
assert_eq!(git_repo("git-upload-pack '/Owner/Demo'"), "Owner/Demo");
}
/// The probe is total: it returns a route for any input, so the
/// dispatch never allocates on a parse failure.
#[test]
fn probe_is_total_over_arbitrary_input() {
assert!(!route("\u{0}\u{1}"));
assert!(route("git-upload-pack '/\u{e9}'"));
assert_eq!(route("\u{0}\u{1}"), ExecRoute::Pipe);
assert_eq!(git_repo("git-upload-pack '/\u{e9}'"), "\u{e9}");
}
}
+181
View File
@@ -0,0 +1,181 @@
//! The git mapping (`w3-git-mapping`) — the git branch of the spine
//! (ADR-006 decision 2, exec-only, the alkforge primary purpose): the
//! dispatch's [`ExecRoute::Git`](crate::dispatch::ExecRoute) route
//! dispatches to the published alkgit seam,
//! `serve_duplex_resolved(repo_id, identity, service, duplex, limits,
//! registry, refs, packgen, ingest, push_options_negotiated,
//! pipeline)`, over the russh channel duplex (`Channel::into_stream()`
//! from the store-and-take handoff). Everything POC-3 stage A proved,
//! re-landed as contract.
//!
//! ## The decisions recorded here (the task's decide-and-record set)
//!
//! - **Repo id — validate, never resolve.** The parse stage
//! ([`crate::dispatch`]) normalizes the wire path; this stage
//! validates the id against `alkgit::validate_repo_id` (1–2
//! segments, charset `a-z0-9-_`, ≤ 100 bytes — the ADR-008
//! repo-id grammar) and hands it through untouched to
//! `serve_duplex_resolved`. **alkssh never resolves**: the
//! resolution, the authorize policy and the collapsed generic
//! denial (unknown ≡ unauthorized, one wire shape, no existence
//! oracle) live at the seam. An id failing the *shape* gate is
//! declined here — `invalid-repo-id`, request-level, reason-only on
//! the wire (the reply-vocabulary decision in [`crate::dispatch`]).
//!
//! - **Wire shapes per decline class** (the open/reply vocabulary):
//! - *Request-level* declinables (`unknown-service`, `service-only`
//! — the parse core's; `invalid-repo-id` — this module's gate):
//! `channel_failure`, channel left usable. The reply vocabulary is
//! shared with parse-then-fail; the reason lives in the events
//! only.
//! - *Serving-path* declines (the row owned the channel):
//! seam-unconfigured (feature on, [`crate::GitSeam`] absent) and
//! the identity-less guard — failure + close, from the spawned
//! task, the wave-2 seam-stub shape.
//! - *The seam's own denials* are not alkssh declines: an unknown or
//! ungranted repo flows to `serve_duplex_resolved`, whose
//! collapsed generic denial writes the `ERR access denied: <repo>`
//! band over the channel **before any pack/ref byte**
//! (`SubstrateError::AccessDenied`) — the POC-3 §A.5 byte-order
//! evidence, contract-pinned by the denial rows in
//! `tests/git_mapping.rs`.
//!
//! - **Exit-status mapping** (POC-3 §A.5, decided):
//! [`serve_git_row`] maps the serve outcome onto exactly two
//! codes — `Ok(())` → `exit-status(0)` (vanilla git exits 0 on
//! success) and every `SubstrateError` → `exit-status(1)`. `1` for
//! `AccessDenied` is the POC's wire evidence (the russh client
//! observed `ExitStatus{1}` after the denial band); `Io`/`Protocol`
//! take the same code (the service ran and failed). The
//! machinery-misuse variants (`ResetRequired`/`WriterTaken`/
//! `RawTailUnavailable`) never arise from the serving entry
//! (substrate-internal misuse, never a wire shape) and are
//! defensively mapped to `1`. `255` is never fabricated — it is
//! OpenSSH's could-not-start semantic, which this door never claims.
//! The status is sent **before** close, from the spawned serving
//! task via the cloned `Session::handle()`
//! ([`crate::serve`]'s mechanics; `mapping.md`'s exit_status row).
//!
//! - **Identity ride** (`w2-auth-bridge`'s parked item, discharged
//! here): the resolved identity — the bridge's
//! `auth_succeeded`-state ride-along in handler state — is cloned
//! into the serving task and passed on
//! `serve_duplex_resolved`'s identity param, so the visible-surface
//! invariant is observable through the git row: alkgit's
//! `authorize` evaluation is the live witness (a granted-private
//! repo serves, an ungranted one denies — the harness rows pin
//! both). A handler state without an identity is unreachable (russh
//! authorizes before channels exist) and declines serving-path
//! (`no-identity`) — never anonymous serving.
//!
//! - **Process-mode dialect**: alkgit speaks first (fetch: the V2
//! advertisement, no `# service=` prefix; push: the V0 ref
//! advertisement) — nothing alkssh-side; the byte-level rows in
//! `tests/git_mapping.rs` verify the dialect holds over the channel
//! duplex.
//!
//! The serve outcome's event shape: the serving-path lifecycle only
//! (started / ended — the vocabulary's `warn`-failed when the wire
//! reporting breaks). A serve `Err` is *not* a decline event: the
//! substrate already mapped its own error onto the wire (bands, the
//! denial), and the client's observation is the exit-status above.
use std::sync::Arc;
use alkcall::core::auth::Identity;
use alkgit::{Service, SubstrateError};
use russh::Channel;
use russh::server::Handle;
use crate::config::GitSeam;
use crate::dispatch::GitServiceRoute;
use crate::observe;
/// The git row's validation gate: the id's shape against
/// `alkgit::validate_repo_id` — the ADR-008 repo-id grammar for
/// *rejection* only (the mapping-free posture; the id is never
/// decomposed, joined, or normalized into paths beyond
/// [`crate::dispatch`]'s quote/slash/`.git` stage).
pub(crate) fn repo_id_validates(repo_id: &str) -> bool {
alkgit::validate_repo_id(repo_id).is_ok()
}
/// The ADR-016 service wire strings → alkgit's service selector (the
/// one mapping point; [`Service`] chooses the advertisement and state
/// machine before any protocol byte).
pub(crate) fn alkgit_service(route: GitServiceRoute) -> Service {
match route {
GitServiceRoute::UploadPack => Service::UploadPack,
GitServiceRoute::ReceivePack => Service::ReceivePack,
}
}
/// The exit-status mapping (see the module section): the serve outcome
/// → the one code the client observes (`0` clean, `1` otherwise).
fn exit_code(outcome: &Result<(), SubstrateError>) -> u32 {
match outcome {
Ok(()) => 0,
Err(_) => 1,
}
}
/// The spawned git serving path: dispatch to the seam over the channel
/// duplex, then the exit-status-then-close report through the cloned
/// handle. The channel was taken from the handler's store by the
/// dispatch (the store-and-take handoff); the spawned task owns the
/// stream and its report surface. A failing report send means the
/// session is gone — the lifecycle's `warn`-failed records it (the
/// one warn of the vocabulary).
pub(crate) async fn serve_git_row(
handle: Handle,
channel: Channel<russh::server::Msg>,
route: GitServiceRoute,
repo_id: String,
identity: Option<Identity>,
seam: GitSeam,
) {
let channel_id = channel.id();
let channel_number = channel_id.number();
observe::serving_path_started("exec_request", channel_number, Some("git"));
let Some(identity) = identity else {
observe::decline_serving("exec_request", "no-identity", channel_number, Some("git"));
let error = match handle.channel_failure(channel_id).await {
Ok(()) => handle.close(channel_id).await.err(),
Err(error) => Some(error),
};
report_lifecycle(channel_number, error);
return;
};
let outcome = alkgit::serve_duplex_resolved(
&repo_id,
Some(&identity),
alkgit_service(route),
channel.into_stream(),
seam.limits,
Arc::clone(&seam.registry),
Arc::clone(&seam.refs),
Arc::clone(&seam.packgen),
Arc::clone(&seam.ingest),
seam.push_options_negotiated,
Arc::clone(&seam.pipeline),
)
.await;
let code = exit_code(&outcome);
let error = match handle.exit_status_request(channel_id, code).await {
Ok(()) => handle.close(channel_id).await.err(),
Err(error) => Some(error),
};
report_lifecycle(channel_number, error);
}
fn report_lifecycle(channel_number: u32, error: Option<()>) {
match error {
Some(error) => {
observe::serving_path_failed("exec_request", channel_number, &error, Some("git"))
}
None => observe::serving_path_ended("exec_request", channel_number, Some("git")),
}
}
+188 -68
View File
@@ -1,7 +1,9 @@
//! The per-connection server handler — the ADR-004 dispositional auth
//! bridge, plus the parse-only dispatch and the fail-closed decline
//! bridge, plus the exec-grammar dispatch and the fail-closed decline
//! core (`w2-dispatch-decline-core`; `w2-auth-bridge` filled the auth
//! hooks).
//! hooks; `w3-git-mapping` replaced the skeleton probe with the full
//! grammar, landed the git row's alkgit dispatch, and with it the
//! store-and-take channel handoff).
//!
//! ## The auth bridge (ADR-004, decisions 1–5)
//!
@@ -57,19 +59,33 @@
//! channel (third-party-upstream note; file upstream first).
//!
//! The session rows are **parse-only**: `exec_request` parses first
//! (a git-parseable command routes to the git row, anything else to
//! the session pipe row — [`crate::dispatch::classify_exec`]);
//! `pty_request` / `shell_request` route to the session rows. Nothing
//! is served before a request exists — no request, no serving, so a
//! request-less session channel declines as unfeatured by
//! construction. **Decline first, alloc never**: every decline here
//! precedes any channel/resource allocation (a leaked channel alloc
//! is a capability leak — the POC-3 stage-A rule). The channel handle
//! a confirmed `session` open produces is *not* consumed this wave —
//! the store-and-take handoff (`channels.remove(&id)` → `into_stream`
//! in the spawned serving task) is wave-3 mapping material; the
//! routing decisions around it are already this wave's, because the
//! routing stubs spawn their decline at the seam.
//! (the full grammar, [`crate::dispatch::parse_exec`] — a git route to
//! the git row, a declined-with-reason grammar class to its honest
//! decline, anything else to the session pipe row); `pty_request` /
//! `shell_request` route to the session rows. Nothing is served before
//! a request exists — no request, no serving, so a request-less
//! session channel declines as unfeatured by construction.
//! **Decline first, alloc never**: every decline here precedes any
//! channel/resource allocation (a leaked channel alloc is a
//! capability leak — the POC-3 stage-A rule); the per-length
//! `max_exec_command_len` cap is enforced before the parse itself.
//!
//! ## The store-and-take channel handoff (`w3-git-mapping`)
//!
//! A confirmed `session` open **stores** its [`Channel`] in
//! per-connection handler state ([`Self::take_stored_channel`]'s
//! supplier: `channel_open_session` accepts, then inserts — the
//! POC-3 §A.4 accepted-but-held shape); the git row's `exec_request`
//! **takes** it (`channels.remove(&id)` → `into_stream()` in the
//! spawned serving task) and the session's packet loop keeps owning
//! the store: `channel_close` removes whatever it held (a channel
//! that served or declined without an exec frees there), and every
//! mutation happens on the loop's task — single-writer, no lock. A
//! git exec on a channel whose stored handle is already gone
//! (re-served, or never held — unvanilla) declines serving-path.
//! The other session rows currently leave the handle stored until
//! the close releases it (the `w3-tty-session` mapping will take it
//! the same way).
//!
//! ## The spawn-and-return structural constraint (ADR-006 decision 4)
//!
@@ -119,14 +135,22 @@
//! - **Request-level declines** — [`Session::channel_failure`]: the
//! SSH request-reply vocabulary has no failure text, so a request
//! decline's "reason" is the routing decision itself (documented
//! above and per feature build in `mapping.md`'s decline matrix).
//! A *capability* decline answers failure and leaves the channel
//! usable (the POC-1 §5 session-survives-declines posture); a
//! *serving-path* decline (the wave-3 seam stubs) answers failure
//! and closes the channel — the vanilla-clean shape for a row that
//! owns the channel. The serving-path decline now rides the spawned
//! serving task (ADR-006 decision 4, §The spawn-and-return
//! structural constraint above); the wire shape is unchanged.
//! above, in [`crate::dispatch`]'s grammar, and per feature build in
//! `mapping.md`'s decline matrix). A *capability* decline answers
//! failure and leaves the channel usable (the POC-1 §5
//! session-survives-declines posture) — including the git grammar's
//! declinables (`unknown-service` / `service-only` /
//! `invalid-repo-id`, the parsed reply shapes, `w3-git-mapping`);
//! a *serving-path* decline (a routable row that owns the channel:
//! the seam-unconfigured git row and the unvanilla
//! channel-not-held / identity-less guards) answers failure and
//! closes the channel — the vanilla-clean shape for a row that owns
//! the channel. The serving-path decline rides the spawned serving
//! task (ADR-006 decision 4, §The spawn-and-return structural
//! constraint above); the wire shape is unchanged. The real
//! serve's own outcomes are not declines: the substrate maps them
//! (denial band before any protocol byte; exit-status then close —
//! the `exit_code` mapping decision recorded in [`crate::git_map`]).
//!
//! ## The event vocabulary on every decline (`w3-decline-tracing`)
//!
@@ -153,10 +177,11 @@
//! the feature gate first (an unfeatured build declines everything
//! before the cap matters), the cap gate before any accept — and
//! before the tunnel seam stub, whose decline opens nothing and so
//! must not consume a count. The `max_exec_command_len` cap stays
//! reserved for wave 3's grammar (carried by [`crate::Limits`], not
//! enforced here); the per-identity policy layer arrives with the
//! alkcall bridge.
//! must not consume a count. The `max_exec_command_len` cap is
//! enforced at `exec_request` *before* the parse and any allocation
//! (`w3-git-mapping`): an over-length wire command declines
//! request-level, unfeatured-or-not. The per-identity policy layer
//! arrives with the alkcall bridge.
//!
//! ## Test surface
//!
@@ -169,6 +194,7 @@
//! the tests call with ordinary key material; the wire-level rows
//! exercise the hook wiring itself, end to end.
use std::collections::HashMap;
use std::sync::Arc;
use alkcall::core::auth::Identity;
@@ -180,9 +206,11 @@ use russh::{Channel, ChannelId, Pty};
use crate::channel_cap::ChannelCap;
use crate::config::ServeConfig;
use crate::dispatch;
use crate::dispatch::{self, ExecRoute};
use crate::error::AdapterError;
use crate::fingerprint::fingerprint;
#[cfg(feature = "git")]
use crate::git_map;
use crate::observe;
use crate::serve;
@@ -200,6 +228,13 @@ pub struct SessionHandler {
serve: Arc<ServeConfig>,
channel_cap: ChannelCap,
resolved_identity: Option<Identity>,
/// The store-and-take handoff (`w3-git-mapping`): confirmed
/// session channels held from the open until their row takes or
/// the close releases them. Written only by the packet loop's
/// hooks (`channel_open_session` inserts; `exec_request` removes
/// for the git row; `channel_close` removes) — single-writer, no
/// lock.
channels: HashMap<ChannelId, Channel<Msg>>,
}
impl SessionHandler {
@@ -212,9 +247,20 @@ impl SessionHandler {
serve,
channel_cap: ChannelCap::default(),
resolved_identity: None,
channels: HashMap::new(),
}
}
/// Takes a stored channel handle (the store half of the
/// store-and-take handoff): the git row's dispatch removes the
/// handle the open stored, handing the stream into the spawned
/// serving task. `None` when nothing is held — a re-served or
/// never-held channel (unvanilla; declined serving-path).
#[cfg(feature = "git")]
fn take_stored_channel(&mut self, channel: ChannelId) -> Option<Channel<Msg>> {
self.channels.remove(&channel)
}
/// The identity this connection currently rides — `Some` only after
/// a fingerprint resolution accepted a publickey auth (the bridge's
/// fail-closed rejecting paths never leave a weakly-authorized
@@ -303,6 +349,7 @@ impl SessionHandler {
/// The git-row gate (git-parseable exec): only the `git` feature
/// serves this row.
#[cfg(feature = "git")]
fn serves_git_row(&self) -> bool {
cfg!(feature = "git")
}
@@ -401,9 +448,10 @@ impl russh::server::Handler for SessionHandler {
/// single-writer reasoning in [`ChannelCap`].
async fn channel_close(
&mut self,
_channel: ChannelId,
channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
self.channels.remove(&channel);
self.channel_cap.release();
Ok(())
}
@@ -421,19 +469,20 @@ impl russh::server::Handler for SessionHandler {
/// requests (nothing serves request-less — ADR-006 as amended).
async fn channel_open_session(
&mut self,
_channel: Channel<Msg>,
channel: Channel<Msg>,
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if !self.serves_session_channels() {
observe::decline_open("session", "unfeatured", _channel.id().number());
observe::decline_open("session", "unfeatured", channel.id().number());
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.admit_channel_open() {
reply.accept().await;
self.channels.insert(channel.id(), channel);
} else {
observe::decline_open("session", "cap-exceeded", _channel.id().number());
observe::decline_open("session", "cap-exceeded", channel.id().number());
reply.reject(dispatch::cap_exceeded_open_decline()).await;
}
Ok(())
@@ -547,35 +596,54 @@ impl russh::server::Handler for SessionHandler {
Ok(())
}
/// The parse-only exec dispatch (ADR-006 point 3 as amended):
/// parse first ([`crate::dispatch::classify_exec`]) — before any
/// allocation, decline first, alloc never — then route on the
/// *feature graph* the route carries: a git-parseable command to
/// the git row (a session-only build declines here — the git
/// parse is its gate), anything else to the session pipe row.
/// Invalid UTF-8 parses to the pipe row (parse-then-fail).
/// The exec-grammar dispatch (ADR-006 point 3 as amended; the
/// full grammar, `w3-git-mapping`): the per-length cap checks the
/// wire bytes first (pre-parse, pre-allocation — decline first,
/// alloc never), then [`crate::dispatch::parse_exec`] routes:
/// a git route to the git row (feature-gated — a session-only
/// build declines it as its gate), a grammar declinable to its
/// honest request-level decline, anything else to the session
/// pipe row. Invalid UTF-8 parses to the pipe row (parse-then-
/// fail).
async fn exec_request(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
if data.len() > self.serve.limits.max_exec_command_len {
observe::decline_request("exec_request", "exec-oversize", channel.number(), None);
decline_unfeatured_request(channel, session).await;
return Ok(());
}
let command = std::str::from_utf8(data).unwrap_or("");
match dispatch::classify_exec(command) {
dispatch::ExecRoute::Git => {
match dispatch::parse_exec(command) {
ExecRoute::Git { service, repo } => {
#[cfg(feature = "git")]
if self.serves_git_row() {
git_row_stub(channel, session);
} else {
observe::decline_request(
"exec_request",
"unfeatured",
channel.number(),
Some("git"),
);
decline_unfeatured_request(channel, session).await;
git_row_dispatch(self, channel, session, service, repo);
return Ok(());
}
#[cfg(not(feature = "git"))]
let _ = (&service, &repo);
observe::decline_request(
"exec_request",
"unfeatured",
channel.number(),
Some("git"),
);
decline_unfeatured_request(channel, session).await;
}
dispatch::ExecRoute::Pipe => {
ExecRoute::Declined(decline) => {
observe::decline_request(
"exec_request",
decline.reason(),
channel.number(),
Some("git"),
);
decline_unfeatured_request(channel, session).await;
}
ExecRoute::Pipe => {
if self.serves_session_rows() {
pipe_row_stub(channel, session);
} else {
@@ -775,9 +843,10 @@ async fn seam_stub_decline(
channel: ChannelId,
row: &'static str,
exec_route: Option<&'static str>,
reason: &'static str,
) {
observe::serving_path_started(row, channel.number(), exec_route);
observe::decline_serving(row, "seam-stub", channel.number(), exec_route);
observe::decline_serving(row, reason, channel.number(), exec_route);
let error = match handle.channel_failure(channel).await {
Ok(()) => handle.close(channel).await.err(),
Err(error) => Some(error),
@@ -788,49 +857,100 @@ async fn seam_stub_decline(
}
}
/// Spawns the seam-stub decline as the row's serving path (ADR-006
/// Spawns a serving-path decline as the row's serving path (ADR-006
/// decision 4): the handle is cloned here, the decline runs on its own
/// task, and the hook returns — no serving path awaits inline.
/// task, and the hook returns — no serving path awaits inline. The
/// row never serves, so the decline *is* the path: failure + close,
/// event-shaped ([`crate::observe`]) with the caller's reason.
fn spawn_seam_stub(
channel: ChannelId,
session: &Session,
session: &mut Session,
row: &'static str,
exec_route: Option<&'static str>,
reason: &'static str,
) {
let handle = session.handle();
serve::spawn_serving_path(seam_stub_decline(handle, channel, row, exec_route));
serve::spawn_serving_path(seam_stub_decline(handle, channel, row, exec_route, reason));
}
/// The git row's seam stub — wave 3 lands the alkgit seam here
/// (`parse command → Service + repo → serve_duplex_resolved`), spawned
/// per ADR-006 decision 4; the skeleton declines at the seam, on its
/// own task.
fn git_row_stub(channel: ChannelId, session: &Session) {
spawn_seam_stub(channel, session, "exec_request", Some("git"));
/// The git row's routing stage (feature-gated, `w3-git-mapping`):
/// validation of the normalized id ([`crate::git_map::repo_id_validates`] —
/// reply-shaped `invalid-repo-id`), the seam-unconfigured decline
/// ([`Self::serves_git_row`] alone configures nothing — the
/// unset-declines contract), then the store-and-take handoff and the
/// spawned alkgit dispatch ([`crate::git_map::serve_git_row`], the
/// resolved identity riding along). A routed exec answers
/// `channel_success` inline (the request-reply vocabulary: the request
/// routed; the serving outcome reports through exit-status later —
/// the POC-3 §A.5 shape), never awaited serving.
#[cfg(feature = "git")]
fn git_row_dispatch(
handler: &mut SessionHandler,
channel: ChannelId,
session: &mut Session,
service: dispatch::GitServiceRoute,
repo: String,
) {
if !git_map::repo_id_validates(&repo) {
observe::decline_request(
"exec_request",
"invalid-repo-id",
channel.number(),
Some("git"),
);
let _ = session.channel_failure(channel);
return;
}
let Some(seam) = handler.serve.git_seam.clone() else {
spawn_seam_stub(
channel,
session,
"exec_request",
Some("git"),
"seam-unconfigured",
);
return;
};
let Some(taken) = handler.take_stored_channel(channel) else {
observe::decline_request(
"exec_request",
"channel-not-held",
channel.number(),
Some("git"),
);
let _ = session.channel_failure(channel);
return;
};
let identity = handler.resolved_identity.clone();
let handle = session.handle();
serve::spawn_serving_path(git_map::serve_git_row(
handle, taken, service, repo, identity, seam,
));
let _ = session.channel_success(channel);
}
/// The session pipe row's seam stub — wave 3 lands the alktty pipe
/// seam here (`wrapper ++ [cmd]`, the OQ-08 argv append), spawned per
/// ADR-006 decision 4; the skeleton declines at the seam, on its own
/// task.
fn pipe_row_stub(channel: ChannelId, session: &Session) {
spawn_seam_stub(channel, session, "exec_request", Some("pipe"));
fn pipe_row_stub(channel: ChannelId, session: &mut Session) {
spawn_seam_stub(channel, session, "exec_request", Some("pipe"), "seam-stub");
}
/// The session pty row's seam stub — wave 3 lands the alktty
/// `NegotiateRequest.tty` construction here
/// (`drive_session_pre_negotiated`), spawned per ADR-006 decision 4;
/// the skeleton declines at the seam, on its own task.
fn pty_row_stub(channel: ChannelId, session: &Session) {
spawn_seam_stub(channel, session, "pty_request", None);
fn pty_row_stub(channel: ChannelId, session: &mut Session) {
spawn_seam_stub(channel, session, "pty_request", None, "seam-stub");
}
/// The session shell row's seam stub — wave 3 lands the alktty
/// `NegotiateRequest.cmd` = serve-config shell argv here (OQ-08, no
/// crate default), spawned per ADR-006 decision 4; the skeleton
/// declines at the seam, on its own task.
fn shell_row_stub(channel: ChannelId, session: &Session) {
spawn_seam_stub(channel, session, "shell_request", None);
fn shell_row_stub(channel: ChannelId, session: &mut Session) {
spawn_seam_stub(channel, session, "shell_request", None, "seam-stub");
}
/// The tunnel row's seam stub: the explicit `ConnectFailed` reject
+2
View File
@@ -30,6 +30,8 @@ mod config;
mod dispatch;
mod error;
mod fingerprint;
#[cfg(feature = "git")]
mod git_map;
mod handler;
mod host_key;
mod key_source;
+13 -4
View File
@@ -65,10 +65,19 @@
//! classifications: `"unfeatured"` (feature-gate absence),
//! `"never-feature"` (ADR-003 §5 rows), `"cap-exceeded"`,
//! `"connect-failed"` (the tunnel seam stub's `ConnectFailed`),
//! `"seam-stub"` (the session-row seam stubs), `"unimplemented"`
//! (the subsystem rows — OQ-13 — and the wave-3 listen forms),
//! `"declined-by-design"` (env, OQ-09), and on the auth class
//! `"method-unimplemented"` / `"cert-unsupported"` /
//! `"seam-stub"` (the session-row seam stubs),
//! `"seam-unconfigured"` (the config-driven git-row decline —
//! feature on, [`crate::GitSeam`] unset; `w3-git-mapping`),
//! `"unknown-service"` / `"service-only"` / `"invalid-repo-id"`
//! (the exec grammar's git declinables — the parse core's two and
//! the git row's validation gate; no service token or repo string
//! ever rides — only the vocabulary value),
//! `"exec-oversize"` (the pre-parse `max_exec_command_len` cap),
//! `"no-identity"` / `"channel-not-held"` (the git row's serving-
//! path guards — unreachable in practice, fail-closed),
//! `"unimplemented"` (the subsystem rows — OQ-13 — and the wave-3
//! listen forms), `"declined-by-design"` (env, OQ-09), and on the
//! auth class `"method-unimplemented"` / `"cert-unsupported"` /
//! `"non-ed25519"` / `"fingerprint-unresolved"`.
//! - `exec_route` — only on `exec_request`-row events: the dispatch
//! route the parse routed to (`"git"` | `"pipe"`), so a git row's
+114 -2
View File
@@ -1,7 +1,7 @@
---
id: w3-git-mapping
name: Wave 3 — the git mapping (exec grammar + alkgit dispatch)
status: pending
status: completed
depends_on: [w3-decline-tracing]
scope: moderate
risk: medium
@@ -121,4 +121,116 @@ contract.
## Notes
> To be filled at implementation time.
> To be filled at implementation time.
### Decisions recorded (implementation, 2026-10-28)
- **Course correction to `w3-seam-config`**: `GitSeam` gains
`limits: alkgit::Limits` — `serve_duplex_resolved` requires the
alkgit ADR-009 session budget, which the landed handle had no field
for. alkgit's own contract shapes this ("constructed by the
door/adapter from assembler config"), so the budget rides the seam
(assembly-supplied, `Copy`), not a crate default. First seam
consumer; landing it here, recorded on the field and in the config
docs.
- **The grammar** (`dispatch.rs`, the probe's rows migrated): exact
serving-service token + single separating space + path-shaped
argument (the wave-2 probe's pinned verdicts preserved verbatim —
`git-upload-pack rel` and double-space arguments are *not git*);
quoted shape is fully `'`-wrapped (unterminated → pipe, the probe's
non-path-shape verdict); leading `/` stripped after quotes; **one
trailing `.git` suffix stripped** (the vanilla-clone idiom; the
alkgit registry grammar excludes `.`, so the door normalizes the
idiom rather than asking registries for dotted keys; an empty
remainder — `/.git` — is *kept* and the git row's validation
declines it, never a silent emptying). Validation:
`alkgit::validate_repo_id` at the git row (`git_map.rs`,
feature-gated — the parse core stays feature-free); a shape-invalid
id declines `invalid-repo-id` alkssh-side; a shape-valid id is
handed through untouched and **alkssh never resolves** (resolution,
authorize, and the collapsed generic denial live at the seam).
Unknown services (`git-send-pack`, `git-upload-archive`) and
service-only (including an empty argument) route to
`ExecRoute::Declined` — build-independent, off the pipe row, per
POC-3 §2's honest-surface verdict.
- **Wire shapes per decline class** (recorded): SSH request replies
carry no text, so the reason lives only in the event vocabulary;
the git declinables (`unknown-service` / `service-only` /
`invalid-repo-id` / `exec-oversize`) share the request-level reply
shape with parse-then-fail — `channel_failure`, channel alive
(POC-3's decline shape, POC-1 §5 posture); the reply vocabulary is
the decision ("unknown service" vs "not git at all" differ in
nothing observable on the wire — only the event). Serving-path
declines (the row owned the channel) keep failure+close:
`seam-unconfigured` (feature on, seam unset — the unset-declines
contract; keeps the wave-2 decline-matrix rows' shape in builds
without a configured seam), plus the unreachable-in-practice
guards `no-identity` and `channel-not-held`. The *serve's own*
denials are not alkssh declines: unknown/ungranted repo flows to
alkgit, whose collapsed generic denial writes
`ERR access denied: <repo>` as the **first byte** on the channel
(pinned byte-level in `tests/git_mapping.rs`), then exit-status(1),
then close.
- **Exit mapping** (POC-3 §A.5, decided): `Ok(())` → exit-status(0)
(vanilla git exits 0 on success; the clean-end wire row exercises
the flush-only request's `Ok(None)` → `Ok(())` shape); every
`SubstrateError` → exit-status(1) — `AccessDenied` per the POC's
wire evidence, `Io`/`Protocol` the same (the service ran and
failed), the machinery-misuse variants defensively 1 (they never
arise from the serving entry). `255` never fabricated (OpenSSH's
could-not-start semantic this door never claims). Status before
close, from the spawned task via the cloned handle; a failed report
send is the vocabulary's one `warn` (the wave-2 stub mechanics). A
serve `Err` is *not* a decline event — the substrate already mapped
its own error onto the wire.
- **Store-and-take handoff**: `SessionHandler.channels` (plain
`std` HashMap, single-writer — every mutation on the packet loop's
task). Stored at the confirmed open (accept then insert); taken by
the git row (`remove` → `into_stream()` in the spawned serving
task); released by `channel_close` (the cap is released there too —
one hook, both budgets). The session rows still hold until their
wave-3 tasks take them. A routed exec answers `channel_success`
inline (the POC-3 §A.5 shape — the request routed; the serving
outcome reports through exit-status).
- **Identity** (`w2-auth-bridge`'s parked ride-along, discharged):
the resolved identity is cloned into the serving task and passed on
`serve_duplex_resolved`'s identity param. The cheapest live witness
row: **alkgit's `authorize` evaluation** — two otherwise-identical
rows (private repo granted to the riding id → the advertisement
flows; the same repo ungranted → the denial band + exit 1); no
direct identity introspection needed, and the deny row doubles as
the authorize-path witness. `None` can never occur post-auth and
declines serving-path.
- **Push semantics through the doubles**: the packless CAS path
(alkgit ADR-020 §1) is the pack-free receive-pack row — commands +
flush (caps NUL-attached on the first command line, `report-status`
requested), no pack; the wire layer applies via
`GitRefs::apply_updates` and writes the report (`unpack ok` /
`ok <ref>`). The duplex discipline *drains the raw tail packless*
— the row had to signal client-side EOF after the report flush
(observed; the real client-eof shape), which is also why vanilla
delete-pushes end cleanly. `GitPackIngest::prepare` never fires
packless — asserted.
- **Per-build matrix**: unchanged rows in `tests/dispatch_core.rs`
(their observables hold — the config-driven decline keeps the
failure-then-close shape in seam-less builds; unknown services keep
failure-then-alive), docs/stamps updated for the new decision set;
the git row's serving + grammar-decline rows live in the new
`tests/git_mapping.rs` (empty module in non-git builds, compile-time
feature isolation). `tests/observe.rs`'s git-row event row updated
to the `seam-unconfigured` reason.
- **Real-tool smoke posture** (recorded): the clone smoke (a real
`git clone` of an unborn-HEAD repo over a TCP-mounted adapter — the
same loopback server stack, the accept loop as the assembly's
mount) **requires git ≥ 2.43 and ssh in the environment**;
missing/older tooling is skipped *with a printed reason* —
skipped-with-reason at most, never silently green. The empty-repo
clone exercises the full chain (exec parse → dispatch → the seam
advertisement → ls-refs → exit-status(0)) with zero pack bytes: the
unborn-HEAD line is what real git 2.43's clone consumed (POC-3's
OQ-19 rider shape), so the hand-rolled doubles stay noop-faithful.
- **Verification**: `cargo test` (default build), `cargo test
--features git --test git_mapping`, `cargo test --features session`,
`cargo test --all-features`, `cargo clippy --all-targets --all-
features -- -D warnings` ×2 (default + all-features), `cargo fmt
--check`, `cargo doc --no-deps` — all green.
+284
View File
@@ -0,0 +1,284 @@
//! alkssh's git-seam doubles (`w3-git-mapping`) — the hand-rolled
//! in-memory alkgit backend quartet, shaped per
//! `alkgit/src/backend.rs:164-254`'s noop impls (the shapes that file
//! itself pins for dyn compatibility), with recording accessors the
//! rows assert through. alkgit's own `Mem*` constructions are
//! test-private to that crate — this module hand-rolls the minimal
//! surface the git rows need, never a sibling-checkout path, never a
//! copy of alkgit's doubles' *code*.
//!
//! State/accessor split: the doubles are configurable per row (repo
//! records with visibility + grants; advertised ref lines) and
//! record every seam call the serving path makes (resolutions,
//! applied CAS updates, prepared pushes' updates, generated packs) —
//! the dispatch fidelity (which id was resolved, which updates were
//! applied) is observable without alkgit's own test surface.
//!
//! Feature gating: this module is `git`-gated at the include site
//! (`tests/common/mod.rs`) — a non-git build compiles none of it, and
//! alkgit types never appear outside.
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
/// The seam's ref-line shape re-exported for the rows (`RefLine` is
/// the doubles' `list_refs` payload).
pub use alkgit::RefLine;
use alkgit::{
AccessAction, GitPackGen, GitPackIngest, GitRefs, GitRegistry, PreparedPush, PushOptions,
RefOutcome, RefUpdate, RegistryError, RepoRecord, StorageError, Visibility,
};
use async_trait::async_trait;
use gix_hash::ObjectId;
use parking_lot::{Mutex, RwLock};
/// A test oid — deterministic bytes (the row-assertable hex shape).
pub fn oid(byte: u8) -> ObjectId {
ObjectId::from([byte; 20])
}
/// One repo the registry double serves: grants keyed by the resolved
/// identity id (alkgit's `authorize` input — the identity-ride
/// witness), the advertised ref set the refs double lists.
pub struct RepoSetup {
pub repo_id: String,
pub visibility: Visibility,
pub grants: BTreeMap<String, Vec<AccessAction>>,
pub refs: Vec<RefLine>,
}
impl RepoSetup {
pub fn private_granted(repo_id: &str, identity_id: &str, refs: Vec<RefLine>) -> Self {
let mut grants = BTreeMap::new();
grants.insert(
identity_id.to_string(),
vec![AccessAction::Read, AccessAction::Write],
);
Self {
repo_id: repo_id.to_string(),
visibility: Visibility::Private,
grants,
refs,
}
}
pub fn granted(
repo_id: &str,
identity_id: &str,
actions: Vec<AccessAction>,
refs: Vec<RefLine>,
) -> Self {
let mut grants = BTreeMap::new();
grants.insert(identity_id.to_string(), actions);
Self {
repo_id: repo_id.to_string(),
visibility: Visibility::Private,
grants,
refs,
}
}
pub fn private_ungranted(repo_id: &str, refs: Vec<RefLine>) -> Self {
Self {
repo_id: repo_id.to_string(),
visibility: Visibility::Private,
grants: BTreeMap::new(),
refs,
}
}
}
/// One recorded CAS application (the wire layer's single ingestion
/// home, `GitRefs::apply_updates`).
#[derive(Clone, Debug)]
pub struct AppliedUpdates {
pub repo_id: String,
pub updates: Vec<RefUpdate>,
pub atomic: bool,
}
/// One recorded pack ingestion (`GitPackIngest::prepare`).
#[derive(Clone, Debug)]
pub struct PreparedPack {
pub repo_id: String,
pub updates: Vec<RefUpdate>,
pub push_options: Option<PushOptions>,
}
/// One recorded pack generation (`GitPackGen::generate`).
#[derive(Clone, Debug)]
pub struct GeneratedPack {
pub repo_id: String,
pub wants: Vec<ObjectId>,
pub boundary_haves: Vec<ObjectId>,
}
/// The in-memory quartet: one struct, four trait impls — the
/// [`GitSeam`](alkssh::GitSeam) takes a separate `Arc` per backend
/// trait and the rows share this state.
#[derive(Default)]
pub struct MemGit {
repos: RwLock<RepoRegistry>,
refs: RwLock<HashMap<String, Vec<RefLine>>>,
resolves: Mutex<Vec<String>>,
applied: Mutex<Vec<AppliedUpdates>>,
prepared: Mutex<Vec<PreparedPack>>,
generated: Mutex<Vec<GeneratedPack>>,
}
impl MemGit {
pub fn new() -> Self {
Self::default()
}
/// Installs one repo record + advertised ref set.
pub fn install(&self, setup: RepoSetup) {
self.repos
.write()
.insert(setup.repo_id.clone(), (setup.visibility, setup.grants));
self.refs.write().insert(setup.repo_id.clone(), setup.refs);
}
/// Every `GitRegistry::resolve` id, in call order.
pub fn resolved_ids(&self) -> Vec<String> {
self.resolves.lock().clone()
}
/// Every CAS application, in call order.
pub fn applied(&self) -> Vec<AppliedUpdates> {
self.applied.lock().clone()
}
/// Every pack preparation, in call order.
pub fn prepared(&self) -> Vec<PreparedPack> {
self.prepared.lock().clone()
}
/// Every pack generation, in call order.
pub fn generated(&self) -> Vec<GeneratedPack> {
self.generated.lock().clone()
}
}
/// The record for a resolved id — the RepoRecord the seam hands the
/// backend traits.
fn record(id: &str, repos: &RepoRegistry) -> Result<RepoRecord, RegistryError> {
match repos.get(id) {
Some((visibility, grants)) => Ok(RepoRecord {
repo_id: id.to_string(),
visibility: *visibility,
grants: grants.clone(),
}),
None => Err(RegistryError::NotFound),
}
}
/// The registry's per-repo record: visibility + the grant map keyed
/// by the resolved identity id.
type RepoRegistry = HashMap<String, (Visibility, BTreeMap<String, Vec<AccessAction>>)>;
#[async_trait]
impl GitRegistry for MemGit {
async fn resolve(&self, repo_id: &str) -> Result<RepoRecord, RegistryError> {
self.resolves.lock().push(repo_id.to_string());
record(repo_id, &self.repos.read())
}
}
#[async_trait]
impl GitRefs for MemGit {
async fn list_refs(&self, repo: &RepoRecord) -> Result<Vec<RefLine>, StorageError> {
Ok(self
.refs
.read()
.get(&repo.repo_id)
.cloned()
.unwrap_or_default())
}
async fn apply_updates(
&self,
repo: &RepoRecord,
updates: Vec<RefUpdate>,
atomic: bool,
) -> Result<Vec<RefOutcome>, StorageError> {
let applied = AppliedUpdates {
repo_id: repo.repo_id.clone(),
updates: updates.clone(),
atomic,
};
let outcomes = updates
.iter()
.map(|update| RefOutcome {
name: update.name.clone(),
result: Ok(()),
})
.collect();
self.applied.lock().push(applied);
Ok(outcomes)
}
}
#[async_trait]
impl GitPackGen for MemGit {
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> {
self.generated.lock().push(GeneratedPack {
repo_id: repo.repo_id.clone(),
wants,
boundary_haves,
});
Ok(())
}
async fn common_haves(
&self,
_repo: &RepoRecord,
_haves: Vec<ObjectId>,
) -> Result<Vec<ObjectId>, StorageError> {
Ok(Vec::new())
}
}
#[async_trait]
impl GitPackIngest for MemGit {
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> {
self.prepared.lock().push(PreparedPack {
repo_id: repo.repo_id.clone(),
updates: updates.clone(),
push_options,
});
Ok(PreparedPush {
unpack: Ok(()),
updates,
})
}
}
/// Assembles the recording quartet into a
/// [`GitSeam`](alkssh::GitSeam) — one shared `Arc<MemGit>`, the
/// default alkgit budget, the push-options gate off (the v1 default).
pub fn git_seam(mem: &Arc<MemGit>) -> alkssh::GitSeam {
alkssh::GitSeam {
limits: alkgit::Limits::default(),
registry: Arc::clone(mem) as _,
refs: Arc::clone(mem) as _,
packgen: Arc::clone(mem) as _,
ingest: Arc::clone(mem) as _,
push_options_negotiated: false,
pipeline: Arc::new(tokio::sync::Semaphore::new(2)),
}
}
+7
View File
@@ -53,6 +53,13 @@
/// `tests/common/harness.rs`).
pub mod harness;
/// The git-seam doubles (`w3-git-mapping`, `git`-gated — alkssh's
/// hand-rolled minimal alkgit quartet per `alkgit/src/backend.rs`'s
/// noop shapes; the module's own doc records the decisions:
/// `tests/common/git_doubles.rs`).
#[cfg(feature = "git")]
pub mod git_doubles;
use std::collections::HashMap;
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
+19 -11
View File
@@ -210,9 +210,12 @@ mod default_build {
}
// ---------------------------------------------------------------------------
// git-only build: routes git-parseable exec to the git row (stub at
// the seam); declines pty/shell, plain exec, all other opens
// (mapping.md §Decline matrix — git row).
// git-only build: routes git-parseable exec to the git row; declines
// pty/shell, plain exec, all other opens (mapping.md §Decline matrix —
// git row). The harness builds no seam, so the git row's decline is
// the config-driven one (`w3-git-mapping`: seam-unconfigured — the
// unset-declines contract, serving-path shape); the serving rows ride
// `tests/git_mapping.rs`.
// ---------------------------------------------------------------------------
#[cfg(all(feature = "git", not(feature = "session"), not(feature = "tunnel")))]
@@ -236,9 +239,11 @@ mod git_build {
}
/// - *Contract stamp:* ADR-006 point 3 (parse-only: a
/// git-parseable exec routes to the git row — the seam stub
/// declines at the family-seam boundary with the serving-path
/// shape: failure then close).
/// git-parseable exec routes to the git row; this build
/// configures no seam, so the row declines at its config gate —
/// the serving-path shape, failure then close). The row's
/// serving surface (seam configured, real dispatch) is
/// `tests/git_mapping.rs`'s.
#[tokio::test]
async fn git_parseable_exec_routes_to_the_git_row_stub() {
let session = authenticated().await;
@@ -278,9 +283,10 @@ mod git_build {
session.unmount().await;
}
/// - *Contract stamp:* POC-3 §2 (unknown git services route off
/// the git row — the skeleton probe's honest surface: they take
/// the pipe-row route, not the git row's close-shape).
/// - *Contract stamp:* POC-3 §2 (unknown git services take the
/// honest decline-with-reason surface — `w3-git-mapping`'s
/// decided class, off the pipe row too; the reply vocabulary is
/// shared with parse-then-fail: failure, channel alive).
#[tokio::test]
async fn unknown_git_services_route_to_the_pipe_row() {
let session = authenticated().await;
@@ -603,8 +609,10 @@ mod tunnel_build {
// ---------------------------------------------------------------------------
// All features: the routing decisions hold with the full graph on —
// every serving path is still a seam stub wave-3 material, and the
// decline classes hold.
// the session rows and the tunnel rows are still their seam stubs
// (w3-tty-session / w3-tunnel-direct material), and the git row
// declines at its config gate this binary builds no seam for (the
// serving rows ride tests/git_mapping.rs).
// ---------------------------------------------------------------------------
#[cfg(all(feature = "git", feature = "session", feature = "tunnel"))]
+872
View File
@@ -0,0 +1,872 @@
//! The `w3-git-mapping` contract rows — the git branch of the spine
//! served for real: the exec grammar routes to the published alkgit
//! seam over the russh channel duplex (ADR-006 decision 2), the
//! process-mode dialect, the denial byte-order, the exit-status
//! mapping, and the identity ride, all observed through the loopback
//! harness against the hand-rolled in-memory alkgit doubles
//! (`tests/common/git_doubles.rs`). The decline-matrix rows *per
//! feature build* stay in `tests/dispatch_core.rs`; this binary adds
//! the git row's serving and grammar-decline surface (feature-gated —
//! in a non-git build the binary compiles empty, per the crates'
//! compile-time feature isolation).
#![allow(dead_code)]
mod common;
#[cfg(feature = "git")]
mod serving {
use std::sync::Arc;
use russh::client::Msg;
use russh::{Channel, ChannelMsg};
use super::common::git_doubles as gd;
use super::common::harness::{
LoopbackSession, authenticate_publickey, await_close, drive, next_message,
open_session_channel, request_exec, request_subsystem,
serve_config as harness_serve_config,
};
use super::common::{
MemHostKeyPolicy, MemIdentityProvider, fixture as fixture_key, identity as ident,
};
use alkssh::{Limits, SshAdapter};
/// Constructs the git build's authenticated loopback session over
/// the doubles: the fixture identity resolves (`alpha` → the
/// grants-key id so alkgit's authorize evaluation sees the ride),
/// the seam carries the recording quartet.
pub(super) async fn git_session(doubles: &Arc<gd::MemGit>) -> LoopbackSession {
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
fixture_key("alpha").fingerprint,
ident("git-rider", &["git"]),
);
let mut serve = harness_serve_config(provider, &[fixture_key("bravo")], Limits::default());
serve.git_seam = Some(gd::git_seam(doubles));
let adapter = SshAdapter::new(serve)
.await
.expect("the git build's adapter constructs");
let policy = Arc::new(MemHostKeyPolicy::with_default(true));
let mut session = drive(adapter, Arc::clone(&policy), Limits::default())
.await
.expect("the loopback mounts");
let result =
authenticate_publickey(&mut session.client, "git-rider", fixture_key("alpha")).await;
assert!(
result.success(),
"the fixture identity authenticates before every git row"
);
session
}
/// Opens a session channel and routes one git exec — the rows'
/// serving driver. Asserts the routed exec's Success reply (the
/// routing decision, on the wire).
pub(super) async fn route_git(session: &LoopbackSession, command: &str) -> Channel<Msg> {
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens in a git build");
request_exec(&channel, command)
.await
.expect("the exec request rides the wire");
let reply = next_message(&mut channel)
.await
.expect("the routed exec answers");
assert!(
matches!(reply, ChannelMsg::Success),
"a routed git exec answers Success, got {reply:?}"
);
channel
}
/// Proves the channel survived a decline — a follow-up request
/// dispatches again (the POC-1 §5 session-survives-declines
/// posture; replicated from `dispatch_core.rs`).
pub(super) async fn still_alive(channel: &mut Channel<Msg>, context: &str) {
request_subsystem(channel, "sftp")
.await
.unwrap_or_else(|e| {
panic!("{context}: the still-open channel carries another request: {e:?}")
});
match next_message(channel).await {
Some(ChannelMsg::Failure) => {}
other => panic!("{context}: expected the surviving-channel failure, got {other:?}"),
}
}
// ------------------------------------------------------------------
// The pkt-line observation surface (test-local framer: channel
// Data messages buffer-decoded one pkt-line at a time).
// ------------------------------------------------------------------
#[derive(Debug)]
pub(super) enum Packet {
Flush,
Payload(Vec<u8>),
}
pub(super) struct PktFramer {
buf: Vec<u8>,
}
impl PktFramer {
pub(super) fn new() -> Self {
Self { buf: Vec::new() }
}
fn take(&mut self) -> Option<Packet> {
if self.buf.len() < 4 {
return None;
}
let hex = std::str::from_utf8(&self.buf[0..4]).ok()?;
let len = usize::from_str_radix(hex, 16).ok()?;
if len == 0 {
self.buf.drain(0..4);
return Some(Packet::Flush);
}
if self.buf.len() < len {
return None;
}
let payload = self.buf[4..len].to_vec();
self.buf.drain(0..len);
Some(Packet::Payload(payload))
}
fn push(&mut self, data: &[u8]) -> Option<Packet> {
self.buf.extend_from_slice(data);
self.take()
}
}
#[derive(Debug)]
pub(super) enum Observed {
Packet(Packet),
ExitStatus(u32),
Close,
Failure,
}
pub(super) async fn next_observed(
channel: &mut Channel<Msg>,
framer: &mut PktFramer,
) -> Option<Observed> {
loop {
if let Some(p) = framer.take() {
return Some(Observed::Packet(p));
}
match next_message(channel).await {
Some(ChannelMsg::Data { data }) => {
if let Some(p) = framer.push(&data) {
return Some(Observed::Packet(p));
}
}
Some(ChannelMsg::ExtendedData { data, .. }) => {
if let Some(p) = framer.push(&data) {
return Some(Observed::Packet(p));
}
}
Some(ChannelMsg::ExitStatus { exit_status }) => {
return Some(Observed::ExitStatus(exit_status));
}
Some(ChannelMsg::Close) => return Some(Observed::Close),
Some(ChannelMsg::Failure) => return Some(Observed::Failure),
Some(_) => continue,
None => return None,
}
}
}
pub(super) async fn next_packet(
channel: &mut Channel<Msg>,
framer: &mut PktFramer,
context: &str,
) -> Packet {
match next_observed(channel, framer).await {
Some(Observed::Packet(p)) => p,
other => panic!("{context}: expected a pkt-line, got {other:?}"),
}
}
/// Drains any buffered pkt-lines until the exit-status arrives;
/// the status-before-close ordering rows lean on the close
/// assertion that follows.
pub(super) async fn expect_exit_status(
channel: &mut Channel<Msg>,
framer: &mut PktFramer,
expected: u32,
context: &str,
) {
loop {
match next_observed(channel, framer).await {
Some(Observed::ExitStatus(code)) => {
assert_eq!(code, expected, "{context}: exit-status code");
return;
}
Some(Observed::Packet(Packet::Flush)) => continue,
other => panic!("{context}: expected exit-status({expected}), got {other:?}"),
}
}
}
pub(super) fn pkt(payload: &[u8]) -> Vec<u8> {
let total = payload.len() + 4;
let mut out = format!("{total:04x}").into_bytes();
out.extend_from_slice(payload);
out
}
pub(super) const FLUSH: &[u8] = b"0000";
const DELIM: &[u8] = b"0001";
/// The oid hex a command/advertisement line carries.
pub(super) fn hex(oid: gix_hash::ObjectId) -> String {
format!("{oid}")
}
#[tokio::test]
async fn advertisement_speaks_first_in_process_mode_byte_level() {
let doubles = Arc::new(gd::MemGit::new());
doubles.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![],
));
let session = git_session(&doubles).await;
let mut channel = route_git(&session, "git-upload-pack '/owner/demo'").await;
let mut framer = PktFramer::new();
let first = next_packet(&mut channel, &mut framer, "the first server byte").await;
let Packet::Payload(first_line) = first else {
panic!("the process-mode dialect writes a pkt-line first, not a flush");
};
assert_eq!(
std::str::from_utf8(&first_line).unwrap(),
"version 2\n",
"the advertisement speaks first (alkgit speaks first) and carries \
no '# service=' smart prefix — process mode, byte-level"
);
let mut caps: Vec<String> = Vec::new();
loop {
match next_packet(&mut channel, &mut framer, "the advertisement").await {
Packet::Flush => break,
Packet::Payload(line) => caps.push(String::from_utf8(line).unwrap()),
}
}
assert!(
caps.iter().any(|c| c.trim().starts_with("ls-refs=")),
"honest capability advertisement: {caps:?}"
);
session.unmount().await;
}
/// - *Contract stamp:* mapping.md §the seams (the git row →
/// `serve_duplex_resolved`) + the exit_status row (clean serve
/// end → exit-status(0) **before** close, from the spawned task)
/// + POC-3 §A.5 (vanilla git exits 0 on success). The registry
/// resolves the handed-through id (`owner/demo`) — dispatch
/// fidelity through the doubles.
#[tokio::test]
async fn ls_refs_round_trips_and_clean_serve_ends_exit_status_zero_before_close() {
let doubles = Arc::new(gd::MemGit::new());
let ref_oid = gd::oid(0xaa);
doubles.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![gd::RefLine {
name: "refs/heads/main".into(),
oid: Some(ref_oid),
peeled: None,
symref_target: None,
}],
));
let session = git_session(&doubles).await;
let mut channel = route_git(&session, "git-upload-pack '/owner/demo'").await;
let mut framer = PktFramer::new();
let mut saw_advertisement = false;
loop {
match next_packet(&mut channel, &mut framer, "advertisement").await {
Packet::Flush => break,
Packet::Payload(line) => {
assert!(
saw_advertisement || is_version_two(&line),
"the advertisement leads (process mode)"
);
saw_advertisement = true;
}
}
}
let mut request = pkt(b"command=ls-refs\n");
request.extend_from_slice(DELIM);
request.extend_from_slice(pkt(b"ref-prefix refs/heads/\n").as_slice());
request.extend_from_slice(FLUSH);
channel
.data_bytes(request)
.await
.expect("the ls-refs request rides");
let line = match next_packet(&mut channel, &mut framer, "the ref line").await {
Packet::Payload(line) => line,
other => panic!("expected the ref line, got {other:?}"),
};
assert_eq!(
String::from_utf8(line).unwrap().trim(),
&format!("{} refs/heads/main", hex(ref_oid)),
"the double's ref list rides the V2 ls-refs machine"
);
match next_packet(&mut channel, &mut framer, "the ls-refs flush").await {
Packet::Flush => {}
other => panic!("expected the flush, got {other:?}"),
}
assert_eq!(
doubles.resolved_ids(),
vec!["owner/demo".to_owned()],
"alkssh handed the normalized id through; it never resolved anything itself"
);
channel
.data_bytes(FLUSH)
.await
.expect("the trailing flush rides");
expect_exit_status(&mut channel, &mut framer, 0, "the clean serve end").await;
assert!(
await_close(&mut channel).await,
"exit-status(0) lands before the close (the spawned task's report)"
);
session.unmount().await;
}
fn is_version_two(line: &[u8]) -> bool {
std::str::from_utf8(line)
.map(|s| s.trim().starts_with("version 2"))
.unwrap_or(false)
}
/// - *Contract stamp:* POC-3 §A.5 / alkgit ADR-008 N-2 (the
/// collapsed generic denial before any protocol byte — unknown ≡
/// unauthorized, no existence oracle): an unknown repo is *not*
/// an alkssh decline — the seam's denial band is the first byte
/// on the channel, then exit-status(1) per the POC's wire
/// evidence, then close (status before close, spawned task).
#[tokio::test]
async fn unknown_repo_denial_band_precedes_any_protocol_byte_then_exit_status_one() {
let doubles = Arc::new(gd::MemGit::new());
let session = git_session(&doubles).await;
let mut channel = route_git(&session, "git-upload-pack '/owner/absent'").await;
let mut framer = PktFramer::new();
let band = match next_packet(&mut channel, &mut framer, "the denial band").await {
Packet::Payload(payload) => payload,
other => panic!(
"the collapsed denial is the first byte — no pack/ref byte precedes it, got {other:?}"
),
};
assert_eq!(
std::str::from_utf8(&band).unwrap().trim(),
"ERR access denied: owner/absent",
"the generic denial names only the client-sent id"
);
expect_exit_status(&mut channel, &mut framer, 1, "the denied serve").await;
assert!(await_close(&mut channel).await, "status before close");
assert!(
doubles.applied().is_empty() && doubles.generated().is_empty(),
"a denied repo never reached the backends (no existence oracle)"
);
session.unmount().await;
}
/// - *Contract stamp:* the visible-surface invariant (AGENTS.md
/// convention 7) + the identity-ride decision (`w2-auth-bridge`'s
/// parked item, discharged `w3-git-mapping`): the resolved
/// identity is the *live* difference between two
/// otherwise-identical rows — a private repo granted to the
/// riding identity serves, the same repo without the grant
/// denies (alkgit's `authorize` evaluation witnesses the ride
/// through the seam — the authorize-path witness row).
#[tokio::test]
async fn identity_ride_witnessed_by_the_authorize_gate() {
let granted = Arc::new(gd::MemGit::new());
granted.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![],
));
let ok_session = git_session(&granted).await;
let mut channel = route_git(&ok_session, "git-upload-pack '/owner/demo'").await;
let mut framer = PktFramer::new();
loop {
match next_packet(&mut channel, &mut framer, "granted advertisement").await {
Packet::Flush => break,
Packet::Payload(_) => {}
}
}
ok_session.unmount().await;
let ungranted = Arc::new(gd::MemGit::new());
ungranted.install(gd::RepoSetup::private_ungranted("owner/demo", vec![]));
let denied_session = git_session(&ungranted).await;
let mut channel = route_git(&denied_session, "git-upload-pack '/owner/demo'").await;
let mut framer = PktFramer::new();
let band = match next_packet(&mut channel, &mut framer, "the ungranted denial").await {
Packet::Payload(payload) => payload,
other => panic!("the ungranted id denies through the seam, got {other:?}"),
};
assert!(band.starts_with(b"ERR access denied"));
expect_exit_status(&mut channel, &mut framer, 1, "the ungranted denial").await;
assert!(await_close(&mut channel).await);
denied_session.unmount().await;
}
/// - *Contract stamp:* mapping.md §the seams (the git row:
/// `serve_duplex_resolved(repo_id, identity, service = push, …)`
/// — the receive-pack direction) + alkgit ADR-020 §1 (the
/// packless CAS path: commands + flush, no pack) + ADR-013 §6–7
/// (`GitRefs::apply_updates` is the single CAS home — the wire
/// layer applies, the double records).
#[tokio::test]
async fn packless_receive_pack_applies_the_cas_and_reports() {
let doubles = Arc::new(gd::MemGit::new());
let ref_oid = gd::oid(0x5a);
doubles.install(gd::RepoSetup::granted(
"owner/demo",
"git-rider",
vec![alkgit::AccessAction::Write],
vec![gd::RefLine {
name: "refs/heads/main".into(),
oid: Some(ref_oid),
peeled: None,
symref_target: None,
}],
));
let session = git_session(&doubles).await;
let mut channel = route_git(&session, "git-receive-pack '/owner/demo'").await;
let mut framer = PktFramer::new();
let advertisement =
match next_packet(&mut channel, &mut framer, "the V0 advertisement").await {
Packet::Payload(line) => line,
other => panic!("push speaks first (V0 advertisement), got {other:?}"),
};
let text = String::from_utf8(advertisement).unwrap();
assert!(
text.contains("refs/heads/main") && text.contains("report-status"),
"the V0 ref lines ride, caps on the first line: {text:?}"
);
match next_packet(&mut channel, &mut framer, "advertisement flush").await {
Packet::Flush => {}
other => panic!("expected the advertisement flush, got {other:?}"),
}
let zero = "0".repeat(40);
let mut command =
pkt(format!("{} {} refs/heads/main\0report-status\n", hex(ref_oid), zero).as_bytes());
command.extend_from_slice(FLUSH);
channel
.data_bytes(command)
.await
.expect("the delete command rides");
let mut report_rows: Vec<String> = Vec::new();
loop {
match next_packet(&mut channel, &mut framer, "the report").await {
Packet::Flush => break,
Packet::Payload(line) => {
report_rows.push(String::from_utf8(line).unwrap().trim().to_owned())
}
}
}
assert_eq!(report_rows, vec!["unpack ok", "ok refs/heads/main"]);
channel
.eof()
.await
.expect("the client signals the packless-push end-of-stream");
expect_exit_status(&mut channel, &mut framer, 0, "the packless push").await;
assert!(await_close(&mut channel).await);
let applied = doubles.applied();
assert_eq!(applied.len(), 1, "the CAS application is the wire layer's");
let applied = &applied[0];
assert_eq!(applied.repo_id, "owner/demo");
assert_eq!(applied.updates.len(), 1);
assert_eq!(applied.updates[0].name, "refs/heads/main");
assert_eq!(applied.updates[0].expected, Some(ref_oid));
assert_eq!(applied.updates[0].new, None, "the zero-id delete shape");
assert!(
doubles.prepared().is_empty(),
"a packless push never reaches pack ingestion"
);
session.unmount().await;
}
/// - *Contract stamp:* POC-3 §2 (unknown services declined
/// honestly — the decided decline-with-reason surface; the
/// record): request-level failure, alive channel, nothing
/// reaches the seam.
#[tokio::test]
async fn unknown_services_decline_request_level_with_alive_channel() {
let doubles = Arc::new(gd::MemGit::new());
doubles.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![],
));
let session = git_session(&doubles).await;
for command in [
"git-send-pack '/owner/demo'",
"git-upload-archive '/owner/demo'",
] {
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_exec(&channel, command)
.await
.expect("the exec rides the wire");
let reply = next_message(&mut channel)
.await
.expect("the decline answers");
assert!(
matches!(reply, ChannelMsg::Failure),
"{command}: the grammar declinable answers failure, got {reply:?}"
);
assert!(
doubles.resolved_ids().is_empty() && doubles.applied().is_empty(),
"{command}: a refused service never reaches the seam"
);
still_alive(&mut channel, command).await;
}
session.unmount().await;
}
/// - *Contract stamp:* POC-3 §A (SSH exec must carry the repo) —
/// service-only declines request-level, alive channel.
#[tokio::test]
async fn service_only_declines_request_level_with_alive_channel() {
let doubles = Arc::new(gd::MemGit::new());
let session = git_session(&doubles).await;
for command in ["git-upload-pack", "git-receive-pack "] {
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_exec(&channel, command)
.await
.expect("the exec rides the wire");
let reply = next_message(&mut channel)
.await
.expect("the decline answers");
assert!(
matches!(reply, ChannelMsg::Failure),
"{command}: failure reply"
);
still_alive(&mut channel, command).await;
}
session.unmount().await;
}
/// - *Contract stamp:* `alkgit::validate_repo_id`'s grammar as
/// the shape gate (the parse hands the id through; invalid ids
/// decline at the git row, never resolve): charset, segments,
/// and the empty id all decline request-level with an alive
/// channel — the `invalid-repo-id` class.
#[tokio::test]
async fn invalid_repo_ids_decline_before_the_seam() {
let doubles = Arc::new(gd::MemGit::new());
doubles.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![],
));
let session = git_session(&doubles).await;
for command in [
"git-upload-pack ''",
"git-upload-pack '/Owner/Demo'",
"git-upload-pack '/a/b/c'",
"git-upload-pack '/.git'",
] {
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_exec(&channel, command)
.await
.expect("the exec rides the wire");
let reply = next_message(&mut channel)
.await
.expect("the decline answers");
assert!(
matches!(reply, ChannelMsg::Failure),
"{command}: the shape gate answers failure, got {reply:?}"
);
assert!(
doubles.resolved_ids().is_empty(),
"{command}: a shape-invalid id never reaches the registry"
);
still_alive(&mut channel, command).await;
}
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
request_exec(&channel, "git-upload-pack '/owner/demo'")
.await
.expect("the exec rides the wire");
assert!(matches!(
next_message(&mut channel).await,
Some(ChannelMsg::Success)
));
session.unmount().await;
}
/// - *Contract stamp:* mapping.md §Data-plane/limits +
/// `Limits::max_exec_command_len` (the per-length cap enforced
/// **before** any parse or allocation; decline-first,
/// alloc-never — the wave-2 carried-not-enforced reservation
/// consumed `w3-git-mapping`): an over-length command answers
/// failure, alive channel, nothing parsed.
#[tokio::test]
async fn over_length_commands_decline_before_the_parse() {
let doubles = Arc::new(gd::MemGit::new());
doubles.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![],
));
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
fixture_key("alpha").fingerprint,
ident("git-rider-cap", &["git"]),
);
let mut serve = harness_serve_config(
provider,
&[fixture_key("bravo")],
Limits {
max_exec_command_len: 32,
..Limits::default()
},
);
serve.git_seam = Some(gd::git_seam(&doubles));
let adapter = SshAdapter::new(serve)
.await
.expect("the cap build's adapter constructs");
let policy = Arc::new(MemHostKeyPolicy::with_default(true));
let mut session = drive(adapter, Arc::clone(&policy), Limits::default())
.await
.expect("the loopback mounts");
let result =
authenticate_publickey(&mut session.client, "git-rider-cap", fixture_key("alpha"))
.await;
assert!(
result.success(),
"the cap build authenticates before its row"
);
let mut channel = open_session_channel(&session.client)
.await
.expect("the session channel opens");
let long_command = format!("git-upload-pack '/owner/{}'", "x".repeat(48));
request_exec(&channel, &long_command)
.await
.expect("the exec rides the wire");
let reply = next_message(&mut channel).await.expect("the cap answers");
assert!(
matches!(reply, ChannelMsg::Failure),
"the cap answers failure"
);
assert!(
doubles.resolved_ids().is_empty(),
"an over-length command is never parsed nor routed"
);
still_alive(&mut channel, "the over-length command").await;
session.unmount().await;
}
// ------------------------------------------------------------------
// The store-and-take handoff, observed: two sequential services on
// two channels of one session — the store is the session task's,
// never poisoned by the taken one.
// ------------------------------------------------------------------
/// - *Contract stamp:* POC-3 §A.4 (the accepted-but-held Channel →
/// `channels.remove(&id)` → `into_stream()` in the spawned task;
/// the session task reuses the store) — the second serve on a
/// fresh channel of the same session rides the same handoff.
#[tokio::test]
async fn store_is_reused_across_sequential_serves() {
let doubles = Arc::new(gd::MemGit::new());
doubles.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![],
));
let session = git_session(&doubles).await;
for round in 1..=2 {
let mut channel = route_git(&session, "git-upload-pack '/owner/demo'").await;
let mut framer = PktFramer::new();
loop {
match next_packet(&mut channel, &mut framer, "round advertisement").await {
Packet::Flush => break,
Packet::Payload(_) => {}
}
}
channel
.data_bytes(FLUSH)
.await
.expect("the trailing flush rides");
expect_exit_status(
&mut channel,
&mut framer,
0,
&format!("serve round {round}"),
)
.await;
assert!(await_close(&mut channel).await);
}
session.unmount().await;
}
}
#[cfg(unix)]
#[cfg(feature = "git")]
mod real_tool {
//! The real-tool smoke (`w3-git-mapping`): one `git clone` (2.43+)
//! through a TCP-mounted adapter against the hand-rolled doubles —
//! the POC-3 stage-A gate re-landed cheaply. Tooling posture
//! (recorded): the smoke requires `git` ≥ 2.43 and `ssh` in the
//! environment; when absent it is **skipped with a printed reason**
//! (skipped-with-reason at most, never silently green).
use std::process::Command;
use std::sync::Arc;
use alkssh::Limits;
use alkssh::SshAdapter;
use super::common::git_doubles as gd;
use super::common::harness::serve_config as harness_serve_config;
use super::common::{MemIdentityProvider, fixture, identity};
#[tokio::test]
async fn real_git_clone_smoke() {
let (git_ok, git_reason) = git_available();
if !git_ok {
eprintln!("SKIPPED (with reason): real-tool clone smoke — {git_reason}");
return;
}
if Command::new("ssh").arg("-V").output().is_err() {
eprintln!("SKIPPED (with reason): real-tool clone smoke — ssh is unavailable");
return;
}
let work = std::env::temp_dir().join(format!("alkssh-git-smoke-{}", std::process::id()));
std::fs::create_dir_all(&work).expect("the smoke workdir creates");
let key_path = work.join("id_ed25519");
std::fs::write(&key_path, fixture("alpha").pem).expect("the fixture key writes");
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
.expect("the key file perms set");
let doubles = Arc::new(gd::MemGit::new());
doubles.install(gd::RepoSetup::private_granted(
"owner/demo",
"git-rider",
vec![gd::RefLine {
name: "HEAD".into(),
oid: None,
peeled: None,
symref_target: Some("refs/heads/main".into()),
}],
));
let provider = Arc::new(MemIdentityProvider::new());
provider.register(
fixture("alpha").fingerprint,
identity("git-rider", &["git"]),
);
let mut serve = harness_serve_config(provider, &[fixture("bravo")], Limits::default());
serve.git_seam = Some(gd::git_seam(&doubles));
let adapter = Arc::new(
SshAdapter::new(serve)
.await
.expect("the smoke adapter constructs"),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("the smoke listener binds");
let port = listener.local_addr().expect("local addr").port();
let accept_task = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let adapter = Arc::clone(&adapter);
tokio::spawn(async move {
let _ = adapter.handle(stream).await;
});
}
});
let ssh = format!(
"ssh -i {} -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o ConnectTimeout=10",
key_path.display()
);
let target = work.join("clone");
let outcome = tokio::process::Command::new("git")
.arg("clone")
.arg(format!("ssh://git-rider@127.0.0.1:{port}/owner/demo.git"))
.arg(&target)
.env("GIT_SSH_COMMAND", &ssh)
.output()
.await
.expect("git clone spawns");
accept_task.abort();
let stderr = String::from_utf8_lossy(&outcome.stderr).to_string();
assert!(
outcome.status.success(),
"vanilla git clone (2.43) through the door succeeds — stderr: {stderr}"
);
assert!(
target.join(".git").is_dir(),
"the clone materializes a repository (the empty-repo clone: unborn HEAD)"
);
assert!(
stderr.contains("empty repository"),
"the unborn-HEAD advertisement is what the clone consumed — stderr: {stderr}"
);
assert!(
doubles.resolved_ids().iter().any(|id| id == "owner/demo"),
"the resolved id is the normalized one (the .git suffix rode off)"
);
let _ = std::fs::remove_dir_all(&work);
}
fn git_available() -> (bool, String) {
let output = Command::new("git").arg("--version").output();
let Ok(output) = output else {
return (false, "git is unavailable".to_owned());
};
let text = String::from_utf8_lossy(&output.stdout).to_string();
let version = text
.split_whitespace()
.last()
.unwrap_or_default()
.split('.')
.filter_map(|c| c.parse::<u32>().ok())
.collect::<Vec<u32>>();
if version.first() == Some(&2) && version.get(1).is_some_and(|minor| *minor >= 43) {
(true, text.trim().to_owned())
} else {
(false, format!("git {text:?} is older than 2.43.0"))
}
}
}
+5 -2
View File
@@ -427,7 +427,10 @@ mod git_build {
/// - *Contract stamp:* ADR-006 decision 4 (spawn-and-return — the
/// serving path is spawned; the lifecycle events started /
/// decline / ended ride the spawned task, in wiring order).
/// decline / ended ride the spawned task, in wiring order). The
/// git row's decline in a seam-less build is config-driven
/// (`w3-git-mapping`: the unset-declines contract — the reason
/// names the seam, not the stub).
#[tokio::test]
async fn git_seam_stub_declines_with_the_full_serving_lifecycle() {
let session = authenticated("git-stub-row").await;
@@ -442,7 +445,7 @@ mod git_build {
let decline = await_event(&[
("row", "exec_request"),
("decline_class", "serving"),
("reason", "seam-stub"),
("reason", "seam-unconfigured"),
("exec_route", "git"),
])
.await;