mod common;
use std::collections::{BTreeMap, HashMap};
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::Duration;
use gix_hash::ObjectId;
use gix_pack::data::entry::Header as PackHeader;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt, ReadBuf};
use tokio::sync::Semaphore;
use alkcall::core::auth::Identity;
use alkcall::core::types::BiStream;
use alkgit::{
serve_duplex, AccessAction, GitPackGen, GitPackIngest, GitRefs, GitRegistryStore, GitSession,
Limits, PushAdvertisedRef, PushReport, PushSendOptions, RefOutcome, RefUpdate, RepoRecord,
Service, SessionError, SubstrateError, Visibility,
};
use common::{
blob_fixture, commit_fixture, delta_stream, loose_body, pack_bytes, pack_entry_base,
pack_entry_delta, parse_pkt_lines, sideband_report_lines, tree_fixture, MemPackGen,
MemPackIngest, MemRefs, MemRegistry,
};
fn repo(id: &str) -> RepoRecord {
RepoRecord {
repo_id: id.to_string(),
visibility: Visibility::Private,
grants: BTreeMap::new(),
}
}
fn identity(id: &str) -> Identity {
Identity {
id: id.to_string(),
scopes: Vec::new(),
resources: HashMap::new(),
}
}
type WireRecord = Arc<Mutex<Vec<u8>>>;
type SessionJoiner = tokio::task::JoinHandle<Result<(), SubstrateError>>;
type Handles = (
Arc<MemRegistry>,
Arc<MemRefs>,
Arc<MemPackGen>,
Arc<MemPackIngest>,
);
fn handles() -> Handles {
let objects = common::MemObjects::default();
(
Arc::new(MemRegistry::default()),
Arc::new(MemRefs::default()),
Arc::new(MemPackGen {
objects: objects.clone(),
}),
Arc::new(MemPackIngest { objects }),
)
}
async fn fixture_fresh(holder: &str) -> (Handles, RepoRecord, Identity) {
let handles = handles();
let record = repo("owner/doubled");
let holder = identity(holder);
let mut granted = record.clone();
granted
.grants
.insert(holder.id.clone(), vec![AccessAction::Write]);
let registry = Arc::clone(&handles.0);
registry.put_repo(&granted).await.expect("the repo row");
(handles, granted, holder)
}
struct RecordRead<S> {
inner: S,
record: Arc<Mutex<Vec<u8>>>,
}
impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for RecordRead<S> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let this = self.as_mut().get_mut();
let before = buf.filled().len();
match Pin::new(&mut this.inner).poll_read(cx, buf) {
Poll::Ready(Ok(())) => {
this.record
.lock()
.expect("spy record")
.extend_from_slice(&buf.filled()[before..]);
Poll::Ready(Ok(()))
}
other => other,
}
}
}
struct RecordWrite<S> {
inner: S,
record: Arc<Mutex<Vec<u8>>>,
shutdowns: Arc<AtomicUsize>,
}
impl<S: tokio::io::AsyncWrite + Unpin> tokio::io::AsyncWrite for RecordWrite<S> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
let this = self.as_mut().get_mut();
this.record
.lock()
.expect("spy record")
.extend_from_slice(buf);
Pin::new(&mut this.inner).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
let this = self.as_mut().get_mut();
Pin::new(&mut this.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
let this = self.as_mut().get_mut();
this.shutdowns.fetch_add(1, Ordering::SeqCst);
Pin::new(&mut this.inner).poll_shutdown(cx)
}
}
fn shutdown_counter() -> Arc<AtomicUsize> {
Arc::new(AtomicUsize::new(0))
}
fn serve_twin(
handles: &Handles,
record: RepoRecord,
holder: &Identity,
) -> (
BiStream,
WireRecord,
WireRecord,
Arc<AtomicUsize>,
SessionJoiner,
) {
let checked = alkgit::AuthorizedRepo::authorize(record, Some(holder), AccessAction::Write)
.expect("the checked write marker");
let refs: Arc<dyn GitRefs> = handles.1.clone();
let packgen: Arc<dyn GitPackGen> = handles.2.clone();
let ingest: Arc<dyn GitPackIngest> = handles.3.clone();
let (client_end, server_end) = duplex(1 << 16);
let received = Arc::new(Mutex::new(Vec::new()));
let sent = Arc::new(Mutex::new(Vec::new()));
let shutdowns = shutdown_counter();
let (server_read, server_write) = tokio::io::split(server_end);
let server = BiStream::from_joined(
RecordRead {
inner: server_read,
record: Arc::clone(&received),
},
RecordWrite {
inner: server_write,
record: Arc::clone(&sent),
shutdowns: Arc::clone(&shutdowns),
},
);
let joiner = tokio::spawn(async move {
serve_duplex(
&checked,
server,
Limits::default(),
Service::ReceivePack,
refs,
packgen,
ingest,
false,
Arc::new(Semaphore::new(
Limits::default().max_concurrent_blocking_tasks,
)),
)
.await
});
let (client_read, client_write) = tokio::io::split(client_end);
(
BiStream::from_joined(client_read, client_write),
received,
sent,
shutdowns,
joiner,
)
}
async fn open_push_session(
handles: &Handles,
record: RepoRecord,
holder: &Identity,
session_limits: Limits,
) -> (
(WireRecord, WireRecord, Arc<AtomicUsize>, SessionJoiner),
GitSession,
) {
let twin = serve_twin(handles, record, holder);
let mut session = GitSession::open_via_channels(
twin.0,
"owner/doubled",
Service::ReceivePack,
session_limits,
)
.expect("the construction");
session
.handshake()
.await
.expect("the push handshake drains the advertisement");
((twin.1, twin.2, twin.3, twin.4), session)
}
fn client_side(stream: tokio::io::DuplexStream) -> BiStream {
let (read, write) = tokio::io::split(stream);
BiStream::from_joined(read, write)
}
async fn seed_ref(handles: &Handles, record: &RepoRecord, name: &str, oid: ObjectId) {
Arc::clone(&handles.1)
.apply_updates(
record,
vec![RefUpdate {
name: name.to_owned(),
expected: None,
new: Some(oid),
}],
false,
)
.await
.expect("the seed");
}
fn create(name: &str, new: ObjectId) -> RefUpdate {
RefUpdate {
name: name.to_owned(),
expected: None,
new: Some(new),
}
}
fn delete(name: &str, current: ObjectId) -> RefUpdate {
RefUpdate {
name: name.to_owned(),
expected: Some(current),
new: None,
}
}
fn update(name: &str, expected: ObjectId, new: ObjectId) -> RefUpdate {
RefUpdate {
name: name.to_owned(),
expected: Some(expected),
new: Some(new),
}
}
fn pkt_line(payload: &[u8]) -> Vec<u8> {
let total = payload.len() + 1 + 4;
let mut out = Vec::with_capacity(total);
out.extend_from_slice(format!("{total:04x}").as_bytes());
out.extend_from_slice(payload);
out.push(b'\n');
out
}
fn split_advertisement(body: &[u8]) -> (&[u8], &[u8]) {
let mut cursor = body;
while !cursor.is_empty() {
let header = std::str::from_utf8(&cursor[..4]).expect("hex header");
let len = usize::from_str_radix(header, 16).expect("hex length");
if len < 4 {
let offset = body.len() - cursor.len();
return (&body[..offset], &body[offset + 4..]);
}
cursor = &cursor[len..];
}
(body, &[])
}
fn first_caps(options: &PushSendOptions, has_deletes: bool) -> String {
let mut tokens = vec![if options.report_status_v2 {
"report-status-v2"
} else {
"report-status"
}];
if options.side_band_64k {
tokens.push("side-band-64k");
}
if options.atomic {
tokens.push("atomic");
}
if has_deletes {
tokens.push("delete-refs");
}
format!(
"{} object-format=sha1 agent=alkgit/{}",
tokens.join(" "),
env!("CARGO_PKG_VERSION")
)
}
struct Chain {
commit_a: ObjectId,
commit_b: ObjectId,
blob_a: ObjectId,
blob_b: ObjectId,
tree_b_body: Vec<u8>,
commit_b_body: Vec<u8>,
}
fn chain_fixture(objects: &common::MemObjects, doubled: &RepoRecord, seed_child: bool) -> Chain {
let (blob_a, blob_a_bytes) = blob_fixture(b"root content");
let blob_b_content: &'static [u8] = b"root content\nchild content";
let blob_b =
gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, blob_b_content)
.expect("the fixture hashes its own object");
let (tree_a, _) = tree_fixture(&[(gix_object::tree::EntryKind::Blob, "file", blob_a)]);
let (commit_a, _) = commit_fixture(tree_a, &[], "alpha");
let (tree_b, tree_b_bytes) = tree_fixture(&[
(gix_object::tree::EntryKind::Blob, "file", blob_a),
(gix_object::tree::EntryKind::Blob, "child", blob_b),
]);
let (commit_b, commit_b_bytes) = commit_fixture(tree_b, &[commit_a], "beta");
for (oid, bytes) in [
(blob_a, blob_a_bytes),
(
tree_a,
tree_fixture(&[(gix_object::tree::EntryKind::Blob, "file", blob_a)]).1,
),
(commit_a, commit_fixture(tree_a, &[], "alpha").1),
] {
objects.put(doubled, oid, bytes);
}
if seed_child {
let blob_b_bytes = blob_fixture(blob_b_content).1;
for (oid, bytes) in [
(blob_b, blob_b_bytes),
(tree_b, tree_b_bytes),
(commit_b, commit_b_bytes),
] {
objects.put(doubled, oid, bytes);
}
}
Chain {
commit_a,
commit_b,
blob_a,
blob_b,
tree_b_body: loose_body(
&tree_fixture(&[
(gix_object::tree::EntryKind::Blob, "file", blob_a),
(gix_object::tree::EntryKind::Blob, "child", blob_b),
])
.1,
)
.1,
commit_b_body: loose_body(&commit_fixture(tree_b, &[commit_a], "beta").1).1,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_ordinary_push_sends_commands_and_pack_then_parses_the_report() {
let (handles, record, holder) = fixture_fresh("u-write").await;
let chain = chain_fixture(handles.3.objects(), &record, true);
let sink = common::SharedSink::default();
Arc::clone(&handles.2)
.generate(
&record,
vec![chain.commit_b],
Vec::new(),
Limits::default(),
Box::new(sink.clone()),
)
.await
.expect("the caller's pack generation");
let pack = sink.snapshot();
let ((wire_received, wire_sent, write_shutdowns, server_joiner), mut session) =
open_push_session(&handles, record.clone(), &holder, Limits::default()).await;
let advertised = session
.push_advertisement()
.await
.expect("the empty repo's advertisement");
assert!(
advertised.is_empty(),
"the empty repo's sentinel parses as the empty set: {advertised:?}"
);
let options = PushSendOptions {
side_band_64k: true,
..Default::default()
};
let report = tokio::time::timeout(
Duration::from_secs(10),
session.push(
vec![create("refs/heads/main", chain.commit_b)],
Some(&mut &pack[..]),
&options,
),
)
.await
.expect("the push is timely")
.expect("the ordinary push lands");
assert_eq!(
report,
PushReport {
unpack: Ok(()),
refs: vec![RefOutcome {
name: "refs/heads/main".to_owned(),
result: Ok(()),
}],
},
"the report parses to the typed per-ref outcome"
);
let rows = Arc::clone(&handles.1)
.list_refs(&record)
.await
.expect("rows");
assert_eq!(rows.len(), 1, "the ref row landed");
assert_eq!(rows[0].oid, Some(chain.commit_b));
assert!(
handles.3.objects().contains(&record, &chain.blob_b),
"the pack's objects landed"
);
let zero = "0".repeat(40);
let caps = first_caps(&options, false);
let expected_client: Vec<u8> = [
pkt_line(format!("{zero} {} refs/heads/main\0 {caps}", chain.commit_b).as_bytes()),
b"0000".to_vec(),
pack,
]
.concat();
assert_eq!(
*wire_received.lock().expect("spy record"),
expected_client,
"the client's wire bytes: the command line (caps NUL-attached, zero-id create shape), \
the command flush, then the pack raw"
);
let sent = wire_sent.lock().expect("spy record").clone();
let (_advertisement, report_part) = split_advertisement(&sent);
assert_eq!(
sideband_report_lines(report_part),
vec!["unpack ok", "ok refs/heads/main"],
"the server's sideband-framed report parses to the same outcomes"
);
drop(session);
server_joiner
.await
.expect("the server join")
.expect("the serving task completed cleanly — the push session closed after the report");
assert_eq!(
write_shutdowns.load(Ordering::SeqCst),
0,
"the client never shut the write half down mid-push (the rider's shape)"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_thin_push_streams_the_callers_thin_pack() {
let (handles, record, holder) = fixture_fresh("u-write").await;
let chain = chain_fixture(handles.3.objects(), &record, false);
assert!(
!handles.3.objects().contains(&record, &chain.blob_b),
"the child-side objects are unseeded — only the pack can land them"
);
let mut offset = 0u64;
let delta = pack_entry_delta(
PackHeader::RefDelta {
base_id: chain.blob_a,
},
delta_stream(12, b"\nchild content").as_slice(),
offset,
);
offset += delta.bytes_in_pack();
let tree_entry = pack_entry_base(gix_object::Kind::Tree, &chain.tree_b_body, offset);
offset += tree_entry.bytes_in_pack();
let commit_entry = pack_entry_base(gix_object::Kind::Commit, &chain.commit_b_body, offset);
let thin_pack = pack_bytes(&[delta, tree_entry, commit_entry]);
let (_, mut session) =
open_push_session(&handles, record.clone(), &holder, Limits::default()).await;
let advertised = session
.push_advertisement()
.await
.expect("the empty repo's advertisement");
assert!(advertised.is_empty());
let options = PushSendOptions::default();
let report = tokio::time::timeout(
Duration::from_secs(10),
session.push(
vec![create("refs/heads/main", chain.commit_b)],
Some(&mut &thin_pack[..]),
&options,
),
)
.await
.expect("the push is timely")
.expect("the thin push lands");
assert_eq!(
report.unpack,
Ok(()),
"the thin pack ingests — the ref-delta base resolves from the server's own objects"
);
assert_eq!(
report.refs,
vec![RefOutcome {
name: "refs/heads/main".to_owned(),
result: Ok(()),
}]
);
assert!(
handles.3.objects().contains(&record, &chain.blob_b),
"the delta'd child blob landed (reconstructed from the server-held base)"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_atomic_push_denial_parses_the_aborted_refs_report() {
let (handles, record, holder) = fixture_fresh("u-write").await;
let chain = chain_fixture(handles.3.objects(), &record, true);
seed_ref(&handles, &record, "refs/heads/keep", chain.commit_a).await;
let (_, mut session) =
open_push_session(&handles, record.clone(), &holder, Limits::default()).await;
let advertised = session
.push_advertisement()
.await
.expect("the advertisement");
assert_eq!(
advertised,
vec![PushAdvertisedRef {
oid: chain.commit_a,
name: "refs/heads/keep".to_owned(),
}],
"the seeded advertisement parses to the typed ref rows"
);
let stale_expected =
gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, b"wrong")
.expect("a decoy oid");
let options = PushSendOptions {
atomic: true,
..Default::default()
};
let report = tokio::time::timeout(
Duration::from_secs(10),
session.push(
vec![
create("refs/heads/other", chain.commit_b),
update("refs/heads/keep", stale_expected, chain.commit_b),
],
None::<&mut &[u8]>,
&options,
),
)
.await
.expect("the push is timely")
.expect("the atomic push reports");
assert_eq!(report.unpack, Ok(()));
assert_eq!(
report.refs,
vec![
RefOutcome {
name: "refs/heads/other".to_owned(),
result: Err("atomic push failure".to_owned()),
},
RefOutcome {
name: "refs/heads/keep".to_owned(),
result: Err("stale info".to_owned()),
},
],
"the aborted-refs report: would-have-succeeded carries the atomic failure, the denied \
ref keeps its own reason"
);
let rows = Arc::clone(&handles.1)
.list_refs(&record)
.await
.expect("rows");
assert_eq!(
rows.len(),
1,
"nothing applied — the transaction aborted whole"
);
assert_eq!(
rows[0].oid,
Some(chain.commit_a),
"the seeded ref untouched"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_packless_delete_push_holds_the_write_side_open_across_both_report_framings() {
for (why, options) in [
(
"the sideband framing",
PushSendOptions {
side_band_64k: true,
..Default::default()
},
),
("the bare framing", PushSendOptions::default()),
] {
let (handles, record, holder) = fixture_fresh("u-write").await;
let chain = chain_fixture(handles.3.objects(), &record, true);
seed_ref(&handles, &record, "refs/heads/dev", chain.commit_a).await;
let ((wire_received, wire_sent, write_shutdowns, server_joiner), mut session) =
open_push_session(&handles, record.clone(), &holder, Limits::default()).await;
let advertised = session
.push_advertisement()
.await
.expect("the advertisement");
assert_eq!(
advertised,
vec![PushAdvertisedRef {
oid: chain.commit_a,
name: "refs/heads/dev".to_owned(),
}],
"{why}: the seeded advertisement"
);
let report = tokio::time::timeout(
Duration::from_secs(10),
session.push(
vec![delete("refs/heads/dev", chain.commit_a)],
None::<&mut &[u8]>,
&options,
),
)
.await
.expect("the push is timely")
.expect("the packless delete push lands");
assert_eq!(
report,
PushReport {
unpack: Ok(()),
refs: vec![RefOutcome {
name: "refs/heads/dev".to_owned(),
result: Ok(()),
}],
},
"{why}: the delete reports ok"
);
let rows = Arc::clone(&handles.1)
.list_refs(&record)
.await
.expect("rows");
assert!(
rows.is_empty(),
"{why}: the ref row is deleted (the packless CAS-only path ran)"
);
let zero = "0".repeat(40);
let caps = first_caps(&options, true);
let expected_client: Vec<u8> = [
pkt_line(format!("{} {zero} refs/heads/dev\0 {caps}", chain.commit_a).as_bytes()),
b"0000".to_vec(),
]
.concat();
assert_eq!(
*wire_received.lock().expect("spy record"),
expected_client,
"{why}: the delete command (old = the current id, new = the zero id — \
ADR-013 §3's delete shape) with the caps NUL-attached, the command flush, and \
nothing after — the packless shape holds the write side open (no shutdown, no \
half-close) until the report"
);
assert_eq!(
write_shutdowns.load(Ordering::SeqCst),
0,
"{why}: the client never shut the write half down before the report"
);
let sent = wire_sent.lock().expect("spy record").clone();
let (_advertisement, report_part) = split_advertisement(&sent);
if options.side_band_64k {
assert_eq!(
sideband_report_lines(report_part),
vec!["unpack ok", "ok refs/heads/dev"],
"{why}: the band-1-wrapped report (the inner framing inside the band)"
);
} else {
assert_eq!(
parse_pkt_lines(report_part),
vec![
b"unpack ok\n".to_vec(),
b"ok refs/heads/dev\n".to_vec(),
Vec::new(),
],
"{why}: the bare report — pkt-lines to the single flush"
);
}
drop(session);
server_joiner.await.expect("the server join").expect(
"{why}: the server took the packless path and closed cleanly — never the \
partial-header EOF error",
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_packless_push_holds_the_write_side_open_and_drains_the_full_report() {
let (client_end, server_end) = duplex(1 << 16);
let facts: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let peer_facts = Arc::clone(&facts);
tokio::spawn(async move {
let (mut peer_read, mut peer_write) = tokio::io::split(server_end);
peer_write
.write_all(
&[
pkt_line(
format!(
"{} capabilities^{{}}\0report-status delete-refs object-format=sha1",
"0".repeat(40)
)
.as_bytes(),
),
b"0000".to_vec(),
]
.concat(),
)
.await
.expect("the advertisement writes");
loop {
let mut header = [0u8; 4];
if peer_read.read_exact(&mut header).await.is_err() {
peer_facts
.lock()
.expect("facts")
.push("eof before the report".to_owned());
return;
}
let len = usize::from_str_radix(std::str::from_utf8(&header).expect("hex header"), 16)
.expect("hex length");
if len < 4 {
break;
}
let mut payload = vec![0u8; len - 4];
peer_read
.read_exact(&mut payload)
.await
.expect("the payload");
}
let eof_watcher = tokio::spawn(async move {
let mut drained = Vec::new();
match peer_read.read_to_end(&mut drained).await {
Ok(_) => drained,
Err(_) => Vec::new(),
}
});
match tokio::time::timeout(Duration::from_millis(900), eof_watcher).await {
Ok(Ok(buf)) => {
if buf.is_empty() {
peer_facts
.lock()
.expect("facts")
.push("eof before the report".to_owned());
} else {
peer_facts
.lock()
.expect("facts")
.push("unexpected bytes while waiting".to_owned());
}
return;
}
Err(_still_open) => {}
Ok(Err(_)) => {
peer_facts
.lock()
.expect("facts")
.push("eof before the report".to_owned());
return;
}
}
peer_facts
.lock()
.expect("facts")
.push("report sent".to_owned());
peer_write
.write_all(
&[
b"000eunpack ok\n".to_vec(),
b"0016ok refs/heads/dev\n".to_vec(),
b"0000".to_vec(),
]
.concat(),
)
.await
.expect("the report writes");
tokio::time::sleep(Duration::from_millis(1500)).await;
let _ = peer_write.shutdown().await;
});
let mut session = GitSession::open_via_channels(
client_side(client_end),
"owner/doubled",
Service::ReceivePack,
Limits::default(),
)
.expect("the construction");
session.handshake().await.expect("the advertisement drain");
let options = PushSendOptions::default();
let current = gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, b"fake")
.expect("a decoy oid");
let report = tokio::time::timeout(
Duration::from_secs(10),
session.push(
vec![delete("refs/heads/dev", current)],
None::<&mut &[u8]>,
&options,
),
)
.await
.expect("the push is timely — it returned on the report, not on the peer's close")
.expect("the report drained in full");
assert_eq!(
report,
PushReport {
unpack: Ok(()),
refs: vec![RefOutcome {
name: "refs/heads/dev".to_owned(),
result: Ok(()),
}],
}
);
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
*facts.lock().expect("facts"),
vec!["report sent".to_owned()],
"the client held the write side open until the report and returned before the peer's \
scripted close (the report side was drained in full)"
);
tokio::time::sleep(Duration::from_millis(1600)).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_push_budgets_fail_closed_typed() {
let (client_end, server_end) = duplex(1 << 16);
tokio::spawn(async move {
let mut stream = server_end;
stream
.write_all(
&[
pkt_line(
format!(
"{} capabilities^{{}}\0report-status object-format=sha1",
"0".repeat(40)
)
.as_bytes(),
),
b"0000".to_vec(),
]
.concat(),
)
.await
.expect("the advertisement writes");
tokio::time::sleep(Duration::from_secs(30)).await;
let _ = stream.shutdown().await;
});
let limits = Limits {
session_wall_clock: Duration::from_millis(400),
..Limits::default()
};
let mut session = GitSession::open_via_channels(
client_side(client_end),
"owner/doubled",
Service::ReceivePack,
limits,
)
.expect("the construction");
session.handshake().await.expect("the advertisement drain");
let current = gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, b"fake")
.expect("a decoy oid");
let error = tokio::time::timeout(
Duration::from_secs(5),
session.push(
vec![delete("refs/heads/x", current)],
None::<&mut &[u8]>,
&Default::default(),
),
)
.await
.expect("the breach answers promptly")
.expect_err("the wall clock breached");
match &error {
SessionError::WallClock { budget } => {
assert_eq!(*budget, Duration::from_millis(400));
}
other => panic!("expected the typed wall-clock breach, got {other:?}"),
}
let again = session
.push(
vec![delete("refs/heads/x", current)],
None::<&mut &[u8]>,
&Default::default(),
)
.await
.expect_err("the session is terminal");
assert_eq!(error, again, "the breach re-reports without re-entry");
let (handles, record, holder) = fixture_fresh("u-write").await;
let chain = chain_fixture(handles.3.objects(), &record, true);
seed_ref(&handles, &record, "refs/heads/dev", chain.commit_a).await;
let client_limits = Limits {
max_pack_size: 8,
..Limits::default()
};
let ((_, _, _, server_joiner), mut session) =
open_push_session(&handles, record.clone(), &holder, client_limits).await;
let error = tokio::time::timeout(
Duration::from_secs(10),
session.push(
vec![delete("refs/heads/dev", chain.commit_a)],
None::<&mut &[u8]>,
&Default::default(),
),
)
.await
.expect("the push is timely")
.expect_err("the report outgrew the budget");
match &error {
SessionError::BudgetExceeded {
what,
budget_name,
limit,
} => {
assert!(what.contains("report"), "{what}");
assert_eq!(*budget_name, "max_pack_size");
assert_eq!(*limit, 8);
}
other => panic!("expected the typed budget breach, got {other:?}"),
}
let again = session
.push(
vec![delete("refs/heads/dev", chain.commit_a)],
None::<&mut &[u8]>,
&Default::default(),
)
.await
.expect_err("the session is terminal");
assert_eq!(error, again, "the breach re-reports without re-entry");
drop(session);
server_joiner
.await
.expect("the server join")
.expect("the server side of the budget breach completes cleanly");
}
#[tokio::test]
async fn local_push_refusals_fail_before_the_wire_and_terminate() {
let (client_end, _server_end) = duplex(1 << 16);
let mut session = GitSession::open_via_channels(
client_side(client_end),
"owner/doubled",
Service::ReceivePack,
Limits::default(),
)
.expect("the construction");
let error = session
.push(vec![], None::<&mut &[u8]>, &Default::default())
.await
.expect_err("an empty command set refuses locally");
match &error {
SessionError::InvalidInput(message) => {
assert!(message.contains("empty"), "{message}");
}
other => panic!("expected the local refusal, got {other:?}"),
}
let again = session
.push(vec![], None::<&mut &[u8]>, &Default::default())
.await
.expect_err("the session is terminal");
assert_eq!(error, again, "the refusal re-reports without re-entry");
let mut wrong_service = GitSession::open_via_channels(
client_side(duplex(1 << 16).0),
"owner/doubled",
Service::UploadPack,
Limits::default(),
)
.expect("the construction");
let current = gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, b"fake")
.expect("a decoy oid");
let error = wrong_service
.push(
vec![delete("refs/heads/x", current)],
None::<&mut &[u8]>,
&Default::default(),
)
.await
.expect_err("push never rides an upload-pack session");
match &error {
SessionError::InvalidInput(message) => {
assert!(message.contains("receive-pack"), "{message}");
}
other => panic!("expected the local refusal, got {other:?}"),
}
}