- git row: a second exec on a served git channel answers channel_failure inline — the serve rides on, no second serving path; race-free by construction (advertisement drained, serve parked at the gated list_refs) - tty rows: the pipe row (second exec) and the shell row (second shell request) — the same inline decline shape, channel still usable - the rows assert the decline event's exact request vocabulary (class request, reason channel-not-held, route git/pipe/absent, debug level, self-describing build) — the class's vocabulary coverage - tests/common/event_capture.rs: the observe-suite's capture convention shared across the mapping binaries - test-only: no production change; the guard held its documented shape in every featured build (verified default / git / session / session,git / tunnel / sftp / --all-features) Verified: cargo test (+ per-feature + --all-features) all green · cargo clippy --all-targets -- -D warnings ✓ · cargo clippy --all-features --all-targets -- -D warnings ✓ · cargo fmt --check ✓ · cargo doc --no-deps 0 warnings · taskgraph validate ✓ (35 tasks)
1094 lines
42 KiB
Rust
1094 lines
42 KiB
Rust
//! 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::event_capture as ec;
|
||
use super::common::git_doubles as gd;
|
||
use super::common::harness::{
|
||
DataGate, LoopbackSession, authenticate_publickey, await_close, drive,
|
||
drive_with_data_gate, 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;
|
||
}
|
||
|
||
/// - *Contract stamp:* the status-before-close hardening
|
||
/// (`w4-git-exit-close` — the wave-3 gate's booked finding 1,
|
||
/// the saturated-`Sender<Msg>` tail made real on the wire): the
|
||
/// serve's *answer volume* exceeds the whole outbound pipeline
|
||
/// (the harness client's packet loop is frozen on the armed
|
||
/// data gate → the transport duplex `DUPLEX_CAPACITY` fills →
|
||
/// the server's flush pends → russh's bounded message queue —
|
||
/// `event_buffer_size`, default 10, pinned below — fills → the
|
||
/// serve's remaining answer writes pend). The gate's release
|
||
/// then drives the serve tail through the just-unwinding
|
||
/// contention: the backlogged answer data drains **through the
|
||
/// same queue the report sends ride**, and the exit-status
|
||
/// still lands **before** the close — the split-halves
|
||
/// construction's property (the report is the only
|
||
/// close-contracting sender; sequential awaited sends on the
|
||
/// one handle), pinning every wire row of this class against
|
||
/// scheduler luck.
|
||
#[tokio::test]
|
||
async fn backlogged_seam_tail_still_reports_exit_status_before_close() {
|
||
assert_eq!(
|
||
russh::server::Config::default().event_buffer_size,
|
||
10,
|
||
"the row's construction pins russh's default message-queue capacity \
|
||
(the saturated-tail scenario's depth) — a russh default change here \
|
||
re-reads this row's freeze-settle yields, not its assertions"
|
||
);
|
||
|
||
let doubles = Arc::new(gd::MemGit::new());
|
||
let ref_oid = gd::oid(0xbb);
|
||
let name_padding = "x".repeat(1200);
|
||
let flooded_refs: Vec<gd::RefLine> = (0..300)
|
||
.map(|n| gd::RefLine {
|
||
name: format!("refs/heads/backlog-{n:03}-{name_padding}"),
|
||
oid: Some(ref_oid),
|
||
peeled: None,
|
||
symref_target: None,
|
||
})
|
||
.collect();
|
||
doubles.install(gd::RepoSetup::private_granted(
|
||
"owner/backlog",
|
||
"git-rider",
|
||
flooded_refs.clone(),
|
||
));
|
||
let serve_gate = doubles.install_gate("owner/backlog");
|
||
|
||
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 backlogged tail's adapter constructs");
|
||
let policy = Arc::new(MemHostKeyPolicy::with_default(true));
|
||
let gate = Arc::new(DataGate::new());
|
||
let mut session = drive_with_data_gate(
|
||
adapter,
|
||
Arc::clone(&policy),
|
||
Limits::default(),
|
||
Some(Arc::clone(&gate)),
|
||
)
|
||
.await
|
||
.expect("the gated loopback mounts");
|
||
let result =
|
||
authenticate_publickey(&mut session.client, "git-rider", fixture_key("alpha")).await;
|
||
assert!(result.success(), "the backlogged tail's row authenticates");
|
||
|
||
let mut channel = route_git(&session, "git-upload-pack '/owner/backlog'").await;
|
||
let mut framer = PktFramer::new();
|
||
loop {
|
||
match next_packet(&mut channel, &mut framer, "backlog advertisement").await {
|
||
Packet::Flush => break,
|
||
Packet::Payload(_) => {}
|
||
}
|
||
}
|
||
|
||
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");
|
||
channel
|
||
.data_bytes(FLUSH)
|
||
.await
|
||
.expect("the trailing flush rides");
|
||
|
||
gate.arm(channel.id());
|
||
serve_gate.release();
|
||
|
||
while gate.held_packets() < 1 {
|
||
tokio::task::yield_now().await;
|
||
}
|
||
for _ in 0..256 {
|
||
tokio::task::yield_now().await;
|
||
}
|
||
|
||
gate.release();
|
||
|
||
let mut seen = 0;
|
||
loop {
|
||
match next_packet(&mut channel, &mut framer, "the backlogged answer").await {
|
||
Packet::Flush => break,
|
||
Packet::Payload(line) => {
|
||
let expected = format!(
|
||
"{} refs/heads/backlog-{seen:03}-{name_padding}",
|
||
hex(ref_oid)
|
||
);
|
||
assert_eq!(
|
||
String::from_utf8(line).unwrap().trim(),
|
||
expected,
|
||
"the backlogged answer's line {seen} rides in order"
|
||
);
|
||
seen += 1;
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(
|
||
seen,
|
||
flooded_refs.len(),
|
||
"the whole 300-line answer drained through the contended queue"
|
||
);
|
||
expect_exit_status(&mut channel, &mut framer, 0, "the backlogged tail").await;
|
||
assert!(
|
||
await_close(&mut channel).await,
|
||
"exit-status(0) lands before the close through the contended tail"
|
||
);
|
||
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;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// The channel-not-held guard (`w4-channel-not-held-rows`): a second
|
||
// exec on an already-served channel declines request-level — the
|
||
// store-and-take handoff holds a channel exactly once, the wave-3
|
||
// gate's booked finding 2.
|
||
// ------------------------------------------------------------------
|
||
|
||
/// - *Contract stamp:* mapping.md §Request-level rows (the git
|
||
/// row's store-and-take handoff: the `channel-not-held` class —
|
||
/// a second exec on an already-served channel answers
|
||
/// `channel_failure`, inline, the first serve rides on — no
|
||
/// second serving path ever spawns) + the event vocabulary
|
||
/// (`src/observe.rs`: class `request`, reason
|
||
/// `channel-not-held`, route `git`; the advertisement drains
|
||
/// first and the serve parks at the gated `list_refs` behind it,
|
||
/// so the wire is quiescent for the decline — race-free by
|
||
/// construction).
|
||
#[tokio::test]
|
||
async fn second_exec_on_a_served_git_channel_declines_channel_not_held() {
|
||
ec::install();
|
||
let doubles = Arc::new(gd::MemGit::new());
|
||
doubles.install(gd::RepoSetup::private_granted(
|
||
"owner/demo",
|
||
"git-rider",
|
||
vec![],
|
||
));
|
||
let serve_gate = doubles.install_gate("owner/demo");
|
||
let session = git_session(&doubles).await;
|
||
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, "the advertisement").await {
|
||
Packet::Flush => break,
|
||
Packet::Payload(_) => {}
|
||
}
|
||
}
|
||
|
||
for _ in 0..200 {
|
||
if doubles.resolved_ids().len() == 1 {
|
||
break;
|
||
}
|
||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||
}
|
||
assert_eq!(
|
||
doubles.resolved_ids(),
|
||
vec!["owner/demo".to_owned()],
|
||
"the first serve resolved and parks at the gated list_refs behind \
|
||
its advertisement — the stored channel is taken, the wire quiescent"
|
||
);
|
||
|
||
request_exec(&channel, "git-upload-pack '/owner/demo'")
|
||
.await
|
||
.expect("the second exec rides the wire");
|
||
match next_message(&mut channel).await {
|
||
Some(ChannelMsg::Failure) => {}
|
||
other => {
|
||
panic!("a second exec on a served channel answers channel_failure, got {other:?}")
|
||
}
|
||
}
|
||
|
||
let event = ec::await_event(&[
|
||
("row", "exec_request"),
|
||
("decline_class", "request"),
|
||
("reason", "channel-not-held"),
|
||
("exec_route", "git"),
|
||
])
|
||
.await;
|
||
ec::assert_request_decline(&event, true);
|
||
assert_eq!(
|
||
doubles.resolved_ids().len(),
|
||
1,
|
||
"the declined re-exec never spawned a second serving path"
|
||
);
|
||
|
||
request_subsystem(&channel, "sftp")
|
||
.await
|
||
.expect("the alive channel carries another request");
|
||
match next_message(&mut channel).await {
|
||
Some(ChannelMsg::Failure) => {}
|
||
other => panic!("the inline decline leaves the channel usable, got {other:?}"),
|
||
}
|
||
|
||
serve_gate.release();
|
||
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"))
|
||
}
|
||
}
|
||
}
|