From 7aac2d11a2effeadd91613b5326266eab2878c68 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 28 Oct 2026 20:03:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(git):=20land=20the=20git=20mapping=20?= =?UTF-8?q?=E2=80=94=20the=20exec=20grammar=20+=20the=20alkgit=20dispatch?= =?UTF-8?q?=20(w3-git-mapping)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/config.rs | 36 +- src/dispatch.rs | 377 ++++++++--- src/git_map.rs | 181 +++++ src/handler.rs | 256 ++++++-- src/lib.rs | 2 + src/observe.rs | 17 +- tasks/implementation/w3-git-mapping.md | 116 +++- tests/common/git_doubles.rs | 284 ++++++++ tests/common/mod.rs | 7 + tests/dispatch_core.rs | 30 +- tests/git_mapping.rs | 872 +++++++++++++++++++++++++ tests/observe.rs | 7 +- 12 files changed, 2012 insertions(+), 173 deletions(-) create mode 100644 src/git_map.rs create mode 100644 tests/common/git_doubles.rs create mode 100644 tests/git_mapping.rs diff --git a/src/config.rs b/src/config.rs index e6407d2..2864a8b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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", &"") .field("refs", &"") .field("packgen", &"") @@ -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("")); assert!(debug.contains("")); assert!(debug.contains("")); diff --git a/src/dispatch.rs b/src/dispatch.rs index 396ed16..f75c8cc 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -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 ''`** → [`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 { + 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}"); } } diff --git a/src/git_map.rs b/src/git_map.rs new file mode 100644 index 0000000..ad3284e --- /dev/null +++ b/src/git_map.rs @@ -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: ` +//! 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, + route: GitServiceRoute, + repo_id: String, + identity: Option, + 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")), + } +} diff --git a/src/handler.rs b/src/handler.rs index 088f56d..c236b3d 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -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, channel_cap: ChannelCap, resolved_identity: Option, + /// 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>, } 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> { + 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, + channel: Channel, 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 diff --git a/src/lib.rs b/src/lib.rs index 2b091b8..0c62e62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/observe.rs b/src/observe.rs index 734c522..a1095e5 100644 --- a/src/observe.rs +++ b/src/observe.rs @@ -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 diff --git a/tasks/implementation/w3-git-mapping.md b/tasks/implementation/w3-git-mapping.md index 47ceb1c..798d179 100644 --- a/tasks/implementation/w3-git-mapping.md +++ b/tasks/implementation/w3-git-mapping.md @@ -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. \ No newline at end of file +> 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: ` 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 `). 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. \ No newline at end of file diff --git a/tests/common/git_doubles.rs b/tests/common/git_doubles.rs new file mode 100644 index 0000000..658ba42 --- /dev/null +++ b/tests/common/git_doubles.rs @@ -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>, + pub refs: Vec, +} + +impl RepoSetup { + pub fn private_granted(repo_id: &str, identity_id: &str, refs: Vec) -> 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, + refs: Vec, + ) -> 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) -> 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, + pub atomic: bool, +} + +/// One recorded pack ingestion (`GitPackIngest::prepare`). +#[derive(Clone, Debug)] +pub struct PreparedPack { + pub repo_id: String, + pub updates: Vec, + pub push_options: Option, +} + +/// One recorded pack generation (`GitPackGen::generate`). +#[derive(Clone, Debug)] +pub struct GeneratedPack { + pub repo_id: String, + pub wants: Vec, + pub boundary_haves: Vec, +} + +/// 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, + refs: RwLock>>, + resolves: Mutex>, + applied: Mutex>, + prepared: Mutex>, + generated: Mutex>, +} + +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 { + self.resolves.lock().clone() + } + + /// Every CAS application, in call order. + pub fn applied(&self) -> Vec { + self.applied.lock().clone() + } + + /// Every pack preparation, in call order. + pub fn prepared(&self) -> Vec { + self.prepared.lock().clone() + } + + /// Every pack generation, in call order. + pub fn generated(&self) -> Vec { + 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 { + 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>)>; + +#[async_trait] +impl GitRegistry for MemGit { + async fn resolve(&self, repo_id: &str) -> Result { + 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, StorageError> { + Ok(self + .refs + .read() + .get(&repo.repo_id) + .cloned() + .unwrap_or_default()) + } + + async fn apply_updates( + &self, + repo: &RepoRecord, + updates: Vec, + atomic: bool, + ) -> Result, 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, + boundary_haves: Vec, + _limits: alkgit::Limits, + _sink: Box, + ) -> 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, + ) -> Result, StorageError> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl GitPackIngest for MemGit { + async fn prepare( + &self, + repo: &RepoRecord, + updates: Vec, + _pack: Box, + push_options: Option, + _limits: alkgit::Limits, + ) -> Result { + 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`, the +/// default alkgit budget, the push-options gate off (the v1 default). +pub fn git_seam(mem: &Arc) -> 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)), + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 300216f..41f5b30 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -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}; diff --git a/tests/dispatch_core.rs b/tests/dispatch_core.rs index fe6b83d..af5123d 100644 --- a/tests/dispatch_core.rs +++ b/tests/dispatch_core.rs @@ -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"))] diff --git a/tests/git_mapping.rs b/tests/git_mapping.rs new file mode 100644 index 0000000..8921d0a --- /dev/null +++ b/tests/git_mapping.rs @@ -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) -> 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 { + 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, 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), + } + + pub(super) struct PktFramer { + buf: Vec, + } + + impl PktFramer { + pub(super) fn new() -> Self { + Self { buf: Vec::new() } + } + + fn take(&mut self) -> Option { + 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 { + 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, + framer: &mut PktFramer, + ) -> Option { + 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, + 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, + 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 { + 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 = 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 = 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::().ok()) + .collect::>(); + 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")) + } + } +} diff --git a/tests/observe.rs b/tests/observe.rs index ed33b23..a2fb8c6 100644 --- a/tests/observe.rs +++ b/tests/observe.rs @@ -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;