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