mod common;
use std::collections::BTreeMap;
use std::io;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::task::{Context, Poll};
use std::time::Duration;
use async_trait::async_trait;
use common::{
blob_fixture, commit_fixture, loose_body, pack_bytes, pack_entry_base, tree_fixture,
MemPackIngest, MemRefs,
};
use futures::io::AsyncWrite as FuturesAsyncWrite;
use futures::stream;
use gix_hash::ObjectId;
use tokio::io::{duplex, AsyncReadExt, AsyncWriteExt};
use tokio::sync::Semaphore;
use alkgit::{
serve_receive_pack, BodyReader, GitPackIngest, GitRefs, Limits, PackTailDiscipline,
PreparedPush, PushOptions, RawTail, RefUpdate, RepoRecord, Request, Service, Session,
StatelessPost, StatelessRequest, SubstrateError, Visibility, ATOMIC_PUSH_FAILURE_REASON,
UNPACKER_ERROR_REASON,
};
const FLUSH: &[u8] = b"0000";
const DELIMITER: &[u8] = b"0001";
fn repo(id: &str) -> RepoRecord {
RepoRecord {
repo_id: id.to_string(),
visibility: Visibility::Private,
grants: BTreeMap::new(),
}
}
fn oid(b: u8) -> ObjectId {
ObjectId::from([b; 20])
}
fn pkt(payload: &[u8]) -> Vec<u8> {
let total = payload.len() + 4;
let mut out = Vec::with_capacity(total);
out.extend_from_slice(format!("{total:04x}").as_bytes());
out.extend_from_slice(payload);
out
}
fn create_command(name: &str, new: ObjectId) -> String {
format!("{} {new} {name}", oid(0))
}
fn delete_command(name: &str, current: ObjectId) -> String {
format!("{current} {} {name}", oid(0))
}
fn update_command(name: &str, current: ObjectId, new: ObjectId) -> String {
format!("{current} {new} {name}")
}
struct Fixture {
oid: ObjectId,
kind: gix_object::Kind,
body: Vec<u8>,
}
fn blob_fix(content: &[u8]) -> Fixture {
let (id, bytes) = blob_fixture(content);
let (kind, body) = loose_body(&bytes);
Fixture {
oid: id,
kind,
body,
}
}
fn tree_fix(entries: &[(gix_object::tree::EntryKind, &str, ObjectId)]) -> Fixture {
let (id, bytes) = tree_fixture(entries);
let (kind, body) = loose_body(&bytes);
Fixture {
oid: id,
kind,
body,
}
}
fn commit_fix(tree: ObjectId, parents: &[ObjectId], message: &str) -> Fixture {
let (id, bytes) = commit_fixture(tree, parents, message);
let (kind, body) = loose_body(&bytes);
Fixture {
oid: id,
kind,
body,
}
}
fn pack_from_fixtures(fixtures: &[&Fixture]) -> Vec<u8> {
let mut offset = 0u64;
let entries: Vec<_> = fixtures
.iter()
.map(|fixture| {
let entry = pack_entry_base(fixture.kind, &fixture.body, offset);
offset += entry.bytes_in_pack();
entry
})
.collect();
pack_bytes(&entries)
}
struct ChainPack {
pack: Vec<u8>,
commit_a: ObjectId,
commit_b: ObjectId,
}
fn chain_pack() -> ChainPack {
let blob_a = blob_fix(b"alpha");
let blob_b = blob_fix(b"beta");
let tree_a = tree_fix(&[(gix_object::tree::EntryKind::Blob, "a.txt", blob_a.oid)]);
let tree_b = tree_fix(&[
(gix_object::tree::EntryKind::Blob, "a.txt", blob_a.oid),
(gix_object::tree::EntryKind::Blob, "b.txt", blob_b.oid),
]);
let commit_a = commit_fix(tree_a.oid, &[], "alpha");
let commit_b = commit_fix(tree_b.oid, &[commit_a.oid], "beta");
let pack = pack_from_fixtures(&[&blob_a, &blob_b, &tree_a, &tree_b, &commit_a, &commit_b]);
ChainPack {
pack,
commit_a: commit_a.oid,
commit_b: commit_b.oid,
}
}
fn request_bytes(caps: &str, commands: &[String], args: &[String]) -> Vec<u8> {
let mut out = pkt(b"command=git-receive-pack\n");
let mut commands = commands.iter();
if let Some(first) = commands.next() {
let line = if caps.is_empty() {
first.clone()
} else {
format!("{first}\0{caps}")
};
out.extend_from_slice(&pkt(line.as_bytes()));
}
for command in commands {
out.extend_from_slice(&pkt(command.as_bytes()));
}
if args.is_empty() {
out.extend_from_slice(FLUSH);
} else {
out.extend_from_slice(DELIMITER);
for arg in args {
out.extend_from_slice(&pkt(arg.as_bytes()));
}
out.extend_from_slice(FLUSH);
}
out
}
fn body_chunks(caps: &str, commands: &[String], pack_chunks: Vec<Vec<u8>>) -> Vec<Vec<u8>> {
let mut chunks = Vec::new();
let mut commands = commands.iter();
if let Some(first) = commands.next() {
let line = if caps.is_empty() {
first.clone()
} else {
format!("{first}\0{caps}")
};
chunks.push(pkt(line.as_bytes()));
}
for command in commands {
chunks.push(pkt(command.as_bytes()));
}
chunks.push(FLUSH.to_vec());
chunks.extend(pack_chunks);
chunks
}
type ChunkStream = stream::Iter<std::vec::IntoIter<io::Result<bytes::Bytes>>>;
type PostBody = BodyReader<ChunkStream, io::Error>;
fn body_reader(chunks: Vec<Vec<u8>>) -> PostBody {
let items: Vec<io::Result<bytes::Bytes>> = chunks
.into_iter()
.map(|chunk| io::Result::Ok(bytes::Bytes::from(chunk)))
.collect();
BodyReader::new(stream::iter(items))
}
#[derive(Clone, Default)]
struct VecSink(Arc<StdMutex<Vec<u8>>>);
impl VecSink {
fn body(&self) -> Vec<u8> {
self.0.lock().expect("sink is not poisoned").clone()
}
}
impl FuturesAsyncWrite for VecSink {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
self.0
.lock()
.expect("sink is not poisoned")
.extend_from_slice(buf);
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.poll_flush(cx)
}
}
fn parse_pkt_lines(body: &[u8]) -> Vec<Vec<u8>> {
let mut lines = Vec::new();
let mut cursor = body;
while !cursor.is_empty() {
let (header, rest) = cursor.split_at(4);
let len = usize::from_str_radix(std::str::from_utf8(header).expect("hex header"), 16)
.expect("hex length");
cursor = rest;
if len < 4 {
lines.push(Vec::new());
continue;
}
let (payload, rest) = cursor.split_at(len - 4);
cursor = rest;
lines.push(payload.to_vec());
}
lines
}
fn text(payload: &[u8]) -> String {
String::from_utf8_lossy(payload)
.trim_end_matches('\n')
.to_owned()
}
fn payloads_as_text(frames: &[Vec<u8>]) -> Vec<String> {
frames.iter().map(|frame| text(frame)).collect()
}
fn sideband_report_lines(packets: &[Vec<u8>]) -> Vec<String> {
let mut payload = Vec::new();
let mut outer_flushes = 0;
for packet in packets {
if packet.is_empty() {
outer_flushes += 1;
continue;
}
assert_eq!(packet[0], 1, "band-1 framing carries the report");
payload.extend_from_slice(&packet[1..]);
}
assert_eq!(outer_flushes, 1, "exactly one outer flush");
let mut inner = parse_pkt_lines(&payload);
let last = inner.pop().expect("the inner framing is non-empty");
assert!(
last.is_empty(),
"the inner flush terminates the report: {last:?}"
);
payloads_as_text(&inner)
}
fn bare_report_lines(packets: &[Vec<u8>]) -> Vec<String> {
let (last, rest) = packets.split_last().expect("the report is non-empty");
assert!(last.is_empty(), "the report ends at the flush: {last:?}");
payloads_as_text(rest)
}
async fn read_pkts(reader: &mut tokio::io::DuplexStream) -> Vec<Vec<u8>> {
let mut packets = Vec::new();
loop {
let mut header = [0u8; 4];
reader
.read_exact(&mut header)
.await
.expect("pkt-line header");
let len = usize::from_str_radix(std::str::from_utf8(&header).expect("hex header"), 16)
.expect("hex length");
if len < 4 {
packets.push(Vec::new());
return packets;
}
let mut payload = vec![0u8; len - 4];
reader
.read_exact(&mut payload)
.await
.expect("pkt-line payload");
packets.push(payload);
}
}
fn fast_limits() -> Limits {
Limits {
packless_peek: Duration::from_millis(100),
..Limits::default()
}
}
async fn seed_ref(refs: &MemRefs, record: &RepoRecord, name: &str, value: ObjectId) {
refs.apply_updates(
record,
vec![RefUpdate {
name: name.to_owned(),
expected: None,
new: Some(value),
}],
false,
)
.await
.unwrap();
}
async fn list_ref(refs: &MemRefs, record: &RepoRecord, name: &str) -> Option<ObjectId> {
refs.list_refs(record)
.await
.unwrap()
.into_iter()
.find(|line| line.name == name)
.and_then(|line| line.oid)
}
fn spawn_duplex_machine(
stream: tokio::io::DuplexStream,
refs: Arc<dyn GitRefs>,
ingest: Arc<dyn GitPackIngest>,
record: RepoRecord,
limits: Limits,
push_options_negotiated: bool,
pipeline: Arc<Semaphore>,
) -> tokio::task::JoinHandle<Result<(), SubstrateError>> {
tokio::spawn(async move {
let mut session = Session::new(stream, limits, Service::ReceivePack);
let request = session
.read_request()
.await
.unwrap()
.expect("the invocation's request");
let tail = session.take_raw_tail().unwrap();
serve_receive_pack(
&mut session,
&request,
tail,
push_options_negotiated,
refs,
ingest,
&record,
&limits,
PackTailDiscipline::Duplex,
pipeline,
)
.await
})
}
async fn stateless_machine(
chunks: Vec<Vec<u8>>,
refs: Arc<dyn GitRefs>,
ingest: Arc<dyn GitPackIngest>,
record: &RepoRecord,
limits: Limits,
push_options_negotiated: bool,
) -> (Result<(), SubstrateError>, VecSink) {
let sink = VecSink::default();
let mut post = StatelessRequest::new(
body_reader(chunks),
sink.clone(),
limits,
Service::ReceivePack,
);
let (command, tail) = match post.read_post().await.expect("the POST parses") {
StatelessPost::Command(command) => (command, post.take_raw_tail().unwrap()),
StatelessPost::Probe => (
Request {
command: "git-receive-pack".to_owned(),
pre_lines: Vec::new(),
args: Vec::new(),
},
RawTail::new(body_reader(Vec::new()), Arc::new(AtomicU64::new(0))),
),
};
let outcome = serve_receive_pack(
post.response(),
&command,
tail,
push_options_negotiated,
refs,
ingest,
record,
&limits,
PackTailDiscipline::Stateless,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
)
.await;
(outcome, sink)
}
struct PrepareSignal {
inner: MemPackIngest,
runs: Arc<AtomicUsize>,
}
impl PrepareSignal {
fn new(run_count: Arc<AtomicUsize>) -> Self {
PrepareSignal {
inner: MemPackIngest::default(),
runs: run_count,
}
}
}
#[async_trait]
impl GitPackIngest for PrepareSignal {
async fn prepare(
&self,
record: &RepoRecord,
updates: Vec<RefUpdate>,
pack: Box<dyn io::Read + Send>,
push_options: Option<PushOptions>,
limits: Limits,
) -> Result<PreparedPush, alkgit::StorageError> {
self.runs.fetch_add(1, Ordering::SeqCst);
self.inner
.prepare(record, updates, pack, push_options, limits)
.await
}
}
fn session_error(result: Result<(), SubstrateError>) -> SubstrateError {
match result {
Err(error) => error,
Ok(()) => panic!("expected a session error"),
}
}
fn protocol_message(error: SubstrateError) -> String {
let SubstrateError::Protocol(message) = error else {
panic!("expected a protocol-class session error: {error:?}");
};
message
}
#[tokio::test]
async fn ordinary_push_ingests_applies_and_reports_in_request_order() {
let chain = chain_pack();
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let mem_ingest = MemPackIngest::default();
let ingest = Arc::new(mem_ingest.clone()) as Arc<dyn GitPackIngest>;
let commands = [
create_command("refs/heads/main", chain.commit_b),
create_command("refs/heads/dev", chain.commit_a),
];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status side-band-64k",
&commands,
&[],
))
.await
.unwrap();
client.write_all(&chain.pack[..1]).await.unwrap();
client.write_all(&chain.pack[1..24]).await.unwrap();
client.write_all(&chain.pack[24..]).await.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let packets = read_pkts(&mut client).await;
assert_eq!(
sideband_report_lines(&packets),
vec![
"unpack ok".to_owned(),
"ok refs/heads/main".to_owned(),
"ok refs/heads/dev".to_owned(),
],
"the report's lines in request order, band-1 framed with the inner flush"
);
drop(client);
let outcome = tokio::time::timeout(Duration::from_secs(5), machine)
.await
.expect("the machine completes")
.unwrap();
assert!(outcome.is_ok(), "the machine ends cleanly: {outcome:?}");
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/main").await,
Some(chain.commit_b),
"the first command applied through the CAS home"
);
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/dev").await,
Some(chain.commit_a),
"the second command applied"
);
assert!(
mem_ingest.objects().contains(&record, &chain.commit_b),
"the pushed objects landed in the backend namespace"
);
}
#[tokio::test]
async fn packless_delete_push_takes_the_cas_only_path_with_a_clean_drain() {
let record = repo("owner/doubled");
let current = oid(0x1b);
let mem_refs = MemRefs::default();
seed_ref(&mem_refs, &record, "refs/heads/gone", current).await;
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let runs = Arc::new(AtomicUsize::new(0));
let ingest = Arc::new(PrepareSignal::new(Arc::clone(&runs))) as Arc<dyn GitPackIngest>;
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status side-band-64k",
&[delete_command("refs/heads/gone", current)],
&[],
))
.await
.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let packets = read_pkts(&mut client).await;
assert_eq!(
sideband_report_lines(&packets),
vec!["unpack ok".to_owned(), "ok refs/heads/gone".to_owned()],
"the packless report shape, no pack involved"
);
drop(client);
let outcome = tokio::time::timeout(Duration::from_secs(5), machine)
.await
.expect("the drain ends when the client closes")
.unwrap();
assert!(outcome.is_ok(), "the session drains cleanly: {outcome:?}");
assert_eq!(
runs.load(Ordering::SeqCst),
0,
"no prepare call runs on the packless path"
);
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/gone").await,
None,
"the CAS deleted the ref"
);
}
#[tokio::test]
async fn packless_create_applies_through_the_cas_home() {
let record = repo("owner/doubled");
let target = oid(0x2c);
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status",
&[create_command("refs/heads/born", target)],
&[],
))
.await
.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let packets = read_pkts(&mut client).await;
assert_eq!(
bare_report_lines(&packets),
vec!["unpack ok".to_owned(), "ok refs/heads/born".to_owned()],
"the bare report shape without sideband selection"
);
drop(client);
assert!(tokio::time::timeout(Duration::from_secs(5), machine)
.await
.unwrap()
.unwrap()
.is_ok());
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/born").await,
Some(target),
"the create applied through the CAS home"
);
}
#[tokio::test]
async fn a_partial_header_at_peek_timeout_is_a_malformed_push_session_error() {
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status",
&[create_command("refs/heads/main", oid(0x2c))],
&[],
))
.await
.unwrap();
client.write_all(b"PA").await.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let outcome = tokio::time::timeout(Duration::from_secs(5), machine)
.await
.expect("the peek's window times out — no park forever")
.unwrap();
let message = protocol_message(session_error(outcome));
assert!(message.contains("pack-header"), "{message}");
assert!(message.contains("malformed push"), "{message}");
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/main").await,
None,
"no ref applied through the malformed push"
);
}
#[tokio::test]
async fn an_atomic_cas_denial_frames_the_outcomes_verbatim() {
let record = repo("owner/doubled");
let chain = chain_pack();
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let commands = [
create_command("refs/heads/main", chain.commit_b),
update_command("refs/heads/dev", oid(0xaa), chain.commit_a),
];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status side-band-64k atomic",
&commands,
&[],
))
.await
.unwrap();
client.write_all(&chain.pack).await.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let packets = read_pkts(&mut client).await;
assert_eq!(
sideband_report_lines(&packets),
vec![
"unpack ok".to_owned(),
format!("ng refs/heads/main {ATOMIC_PUSH_FAILURE_REASON}"),
"ng refs/heads/dev stale info".to_owned(),
],
"the aborted ref reports atomic push failure; the denied ref keeps its own reason"
);
drop(client);
assert!(tokio::time::timeout(Duration::from_secs(5), machine)
.await
.unwrap()
.unwrap()
.is_ok());
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/main").await,
None,
"the atomic rollback applied nothing"
);
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/dev").await,
None,
"the denied ref did not apply"
);
}
#[tokio::test]
async fn an_atomic_push_with_a_rejected_name_aborts_the_whole_push() {
let record = repo("owner/doubled");
let chain = chain_pack();
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let commands = [
create_command("refs/heads/../evil", chain.commit_b),
create_command("refs/heads/main", chain.commit_b),
];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status side-band-64k atomic",
&commands,
&[],
))
.await
.unwrap();
client.write_all(&chain.pack).await.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let packets = read_pkts(&mut client).await;
assert_eq!(
sideband_report_lines(&packets),
vec![
"unpack ok".to_owned(),
"ng refs/heads/../evil funny refname".to_owned(),
format!("ng refs/heads/main {ATOMIC_PUSH_FAILURE_REASON}"),
],
"the rejected name keeps its pinned reason; the aborted refs report the abort"
);
drop(client);
assert!(tokio::time::timeout(Duration::from_secs(5), machine)
.await
.unwrap()
.unwrap()
.is_ok());
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/main").await,
None,
"no ref applied — the atomic push aborted whole"
);
assert_eq!(
mem_refs.list_refs(&record).await.unwrap().len(),
0,
"the backend's ref table never saw any name"
);
}
#[tokio::test]
async fn a_rejected_refname_reports_funny_refname_and_is_excluded_from_the_cas() {
let record = repo("owner/doubled");
let chain = chain_pack();
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let commands = [
create_command("refs/heads/../evil", chain.commit_b),
create_command("refs/heads/main", chain.commit_a),
];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes("report-status", &commands, &[]))
.await
.unwrap();
client.write_all(&chain.pack).await.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let packets = read_pkts(&mut client).await;
assert_eq!(
bare_report_lines(&packets),
vec![
"unpack ok".to_owned(),
"ng refs/heads/../evil funny refname".to_owned(),
"ok refs/heads/main".to_owned(),
],
"report in request order — the rejected slot keeps its place"
);
drop(client);
assert!(tokio::time::timeout(Duration::from_secs(5), machine)
.await
.unwrap()
.unwrap()
.is_ok());
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/main").await,
Some(chain.commit_a),
"the remaining refs applied"
);
assert_eq!(
mem_refs.list_refs(&record).await.unwrap().len(),
1,
"the invalid name never reached the CAS home"
);
}
#[tokio::test]
async fn an_unpack_ng_leg_reports_all_refs_unpacker_error_without_a_cas_call() {
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let runs = Arc::new(AtomicUsize::new(0));
let ingest = Arc::new(PrepareSignal::new(Arc::clone(&runs))) as Arc<dyn GitPackIngest>;
let commands = [
create_command("refs/heads/main", oid(0x2c)),
create_command("refs/heads/other", oid(0x3d)),
];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status side-band-64k",
&commands,
&[],
))
.await
.unwrap();
client.write_all(b"NOPE-NOPE-NO").await.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let packets = read_pkts(&mut client).await;
let lines = sideband_report_lines(&packets);
assert!(
lines[0].starts_with("unpack ng "),
"pack-level reason verbatim: {lines:?}"
);
assert!(!lines[0].contains(UNPACKER_ERROR_REASON), "{lines:?}");
assert_eq!(
lines[1..],
vec![
format!("ng refs/heads/main {UNPACKER_ERROR_REASON}"),
format!("ng refs/heads/other {UNPACKER_ERROR_REASON}"),
],
"all refs report the unpacker-error shape"
);
drop(client);
assert!(tokio::time::timeout(Duration::from_secs(5), machine)
.await
.unwrap()
.unwrap()
.is_ok());
assert_eq!(
runs.load(Ordering::SeqCst),
1,
"the prepare call ran once (the pack failed inside it)"
);
assert_eq!(
mem_refs.list_refs(&record).await.unwrap().len(),
0,
"no ref applied through the failed unpack"
);
}
#[tokio::test]
async fn the_nothing_to_do_shape_replies_flush_with_no_report() {
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let runs = Arc::new(AtomicUsize::new(0));
let ingest = Arc::new(PrepareSignal::new(Arc::clone(&runs))) as Arc<dyn GitPackIngest>;
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes("report-status side-band-64k", &[], &[]))
.await
.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(limits.max_concurrent_blocking_tasks)),
);
let packets = read_pkts(&mut client).await;
assert_eq!(packets.len(), 1, "exactly the reply flush");
assert!(packets[0].is_empty(), "the reply flush has no payload");
drop(client);
assert!(tokio::time::timeout(Duration::from_secs(5), machine)
.await
.unwrap()
.unwrap()
.is_ok());
assert_eq!(
runs.load(Ordering::SeqCst),
0,
"no prepare call ran on the nothing-to-do shape"
);
assert_eq!(
mem_refs.list_refs(&record).await.unwrap().len(),
0,
"no CAS ran"
);
}
#[tokio::test]
async fn shallow_and_object_format_reject_up_front_as_session_errors() {
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(MemRefs::default()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let commands = [
format!("shallow {}", oid(0x1b)),
create_command("refs/heads/main", oid(0x2c)),
];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes("", &commands, &[]))
.await
.unwrap();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
fast_limits(),
false,
Arc::new(Semaphore::new(1)),
);
let message = protocol_message(session_error(machine.await.unwrap()));
assert!(message.contains("shallow"), "{message}");
assert_eq!(mem_refs.list_refs(&record).await.unwrap().len(), 0);
let commands = [create_command("refs/heads/main", oid(0x2c))];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes("object-format=sha1024", &commands, &[]))
.await
.unwrap();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
fast_limits(),
false,
Arc::new(Semaphore::new(1)),
);
let message = protocol_message(session_error(machine.await.unwrap()));
assert!(message.contains("object-format"), "{message}");
assert_eq!(mem_refs.list_refs(&record).await.unwrap().len(), 0);
}
#[tokio::test]
async fn the_pipeline_concurrency_permit_wraps_prepare() {
let record = repo("owner/doubled");
let chain = chain_pack();
let refs = Arc::new(MemRefs::default()) as Arc<dyn GitRefs>;
let runs = Arc::new(AtomicUsize::new(0));
let ingest = Arc::new(PrepareSignal::new(Arc::clone(&runs))) as Arc<dyn GitPackIngest>;
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status",
&[create_command("refs/heads/main", chain.commit_b)],
&[],
))
.await
.unwrap();
client.write_all(&chain.pack).await.unwrap();
let limits = fast_limits();
let pipeline = Arc::new(Semaphore::new(1));
let held = pipeline
.acquire()
.await
.expect("the rows hold the permit first");
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::clone(&pipeline),
);
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
runs.load(Ordering::SeqCst),
0,
"a saturated semaphore delays the prepare call"
);
drop(held);
let packets = read_pkts(&mut client).await;
drop(client);
let outcome = tokio::time::timeout(Duration::from_secs(5), machine)
.await
.expect("the machine completes once the permit frees")
.unwrap();
assert!(outcome.is_ok());
assert_eq!(
runs.load(Ordering::SeqCst),
1,
"prepare ran exactly once, under the permit"
);
let lines = bare_report_lines(&packets);
assert_eq!(
lines,
vec!["unpack ok".to_owned(), "ok refs/heads/main".to_owned()]
);
}
#[tokio::test]
async fn a_pack_size_budget_breach_fails_the_session_closed() {
let record = repo("owner/doubled");
let chain = chain_pack();
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status",
&[create_command("refs/heads/main", chain.commit_b)],
&[],
))
.await
.unwrap();
client.write_all(&chain.pack).await.unwrap();
let limits = Limits {
max_pack_size: 2,
packless_peek: Duration::from_millis(100),
..Limits::default()
};
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(4)),
);
let outcome = tokio::time::timeout(Duration::from_secs(5), machine)
.await
.expect("the breach fails the session promptly")
.unwrap();
let error = session_error(outcome);
let SubstrateError::Protocol(message) = error else {
panic!("the budget breach is the client-fault class: {error:?}");
};
assert!(message.contains("max_pack_size"), "{message}");
assert_eq!(
mem_refs.list_refs(&record).await.unwrap().len(),
0,
"no ref applied through the breached push"
);
}
#[tokio::test]
async fn the_negotiated_push_options_section_is_consumed_before_the_peek() {
let record = repo("owner/doubled");
let chain = chain_pack();
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let commands = [create_command("refs/heads/main", chain.commit_b)];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status push-options",
&commands,
&["private=yes".to_owned()],
))
.await
.unwrap();
client.write_all(&chain.pack).await.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
true,
Arc::new(Semaphore::new(4)),
);
let packets = read_pkts(&mut client).await;
assert_eq!(
bare_report_lines(&packets),
vec!["unpack ok".to_owned(), "ok refs/heads/main".to_owned()],
"the push flowed through, the options section consumed ahead of the peek"
);
drop(client);
assert!(tokio::time::timeout(Duration::from_secs(5), machine)
.await
.unwrap()
.unwrap()
.is_ok());
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/main").await,
Some(chain.commit_b)
);
}
#[tokio::test]
async fn selecting_push_options_with_the_gate_off_fails_closed() {
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status push-options",
&[create_command("refs/heads/main", oid(0x2c))],
&["private=yes".to_owned()],
))
.await
.unwrap();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
fast_limits(),
false,
Arc::new(Semaphore::new(1)),
);
let message = protocol_message(session_error(machine.await.unwrap()));
assert!(message.contains("not advertised"), "{message}");
assert_eq!(mem_refs.list_refs(&record).await.unwrap().len(), 0);
}
#[tokio::test]
async fn the_stateless_tail_ingests_dribbled_pack_bytes() {
let record = repo("owner/doubled");
let chain = chain_pack();
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let mem_ingest = MemPackIngest::default();
let ingest = Arc::new(mem_ingest.clone()) as Arc<dyn GitPackIngest>;
let commands = [
create_command("refs/heads/main", chain.commit_b),
create_command("refs/heads/dev", chain.commit_a),
];
let (outcome, sink) = stateless_machine(
body_chunks(
"report-status side-band-64k",
&commands,
vec![
chain.pack[..1].to_vec(),
chain.pack[1..24].to_vec(),
chain.pack[24..].to_vec(),
],
),
Arc::clone(&refs),
Arc::clone(&ingest),
&record,
fast_limits(),
false,
)
.await;
assert!(outcome.is_ok(), "the machine ends cleanly: {outcome:?}");
let packets = parse_pkt_lines(&sink.body());
assert_eq!(
sideband_report_lines(&packets),
vec![
"unpack ok".to_owned(),
"ok refs/heads/main".to_owned(),
"ok refs/heads/dev".to_owned(),
]
);
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/main").await,
Some(chain.commit_b)
);
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/dev").await,
Some(chain.commit_a)
);
assert!(
mem_ingest.objects().contains(&record, &chain.commit_b),
"the pushed objects landed"
);
}
#[tokio::test]
async fn eof_at_the_tails_start_takes_the_stateless_packless_path() {
let record = repo("owner/doubled");
let current = oid(0x1b);
let mem_refs = MemRefs::default();
seed_ref(&mem_refs, &record, "refs/heads/gone", current).await;
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let runs = Arc::new(AtomicUsize::new(0));
let ingest = Arc::new(PrepareSignal::new(Arc::clone(&runs))) as Arc<dyn GitPackIngest>;
let (outcome, sink) = stateless_machine(
body_chunks(
"report-status side-band-64k",
&[delete_command("refs/heads/gone", current)],
Vec::new(),
),
Arc::clone(&refs),
Arc::clone(&ingest),
&record,
fast_limits(),
false,
)
.await;
assert!(outcome.is_ok());
let packets = parse_pkt_lines(&sink.body());
assert_eq!(
sideband_report_lines(&packets),
vec!["unpack ok".to_owned(), "ok refs/heads/gone".to_owned()],
);
assert_eq!(
runs.load(Ordering::SeqCst),
0,
"no prepare call runs on the packless path"
);
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/gone").await,
None,
"the CAS deleted the ref"
);
}
#[tokio::test]
async fn eof_after_a_partial_header_is_the_stateless_malformed_push_error() {
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let (outcome, sink) = stateless_machine(
body_chunks(
"report-status side-band-64k",
&[create_command("refs/heads/main", oid(0x2c))],
vec![b"PA".to_vec()],
),
Arc::clone(&refs),
Arc::clone(&ingest),
&record,
fast_limits(),
false,
)
.await;
let message = protocol_message(session_error(outcome));
assert!(message.contains("pack-header"), "{message}");
assert!(message.contains("malformed push"), "{message}");
assert!(sink.body().is_empty(), "no report bytes were written");
assert_eq!(mem_refs.list_refs(&record).await.unwrap().len(), 0);
}
#[tokio::test]
async fn the_stateless_nothing_to_do_shape_replies_flush_with_no_report() {
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let (outcome, sink) = stateless_machine(
body_chunks("report-status side-band-64k", &[], Vec::new()),
Arc::clone(&refs),
Arc::clone(&ingest),
&record,
fast_limits(),
false,
)
.await;
assert!(outcome.is_ok());
let packets = parse_pkt_lines(&sink.body());
assert_eq!(packets.len(), 1, "exactly the reply flush");
assert!(packets[0].is_empty(), "the reply flush has no payload");
assert_eq!(mem_refs.list_refs(&record).await.unwrap().len(), 0);
}
#[tokio::test]
async fn a_client_not_selecting_report_caps_gets_no_report_bytes() {
let record = repo("owner/doubled");
let chain = chain_pack();
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let (outcome, sink) = stateless_machine(
body_chunks(
"",
&[create_command("refs/heads/main", chain.commit_b)],
vec![chain.pack.clone()],
),
Arc::clone(&refs),
Arc::clone(&ingest),
&record,
fast_limits(),
false,
)
.await;
assert!(outcome.is_ok());
assert!(sink.body().is_empty(), "no report bytes without the cap");
assert_eq!(
list_ref(&mem_refs, &record, "refs/heads/main").await,
Some(chain.commit_b),
"the CAS applied regardless of the report selection"
);
}
#[tokio::test]
async fn the_reports_framing_follows_the_sideband_selection() {
let record = repo("owner/doubled");
let chain = chain_pack();
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let commands = [create_command("refs/heads/main", chain.commit_b)];
let (mut client, stream) = duplex(1 << 20);
client
.write_all(&request_bytes(
"report-status side-band-64k",
&commands,
&[],
))
.await
.unwrap();
client.write_all(&chain.pack).await.unwrap();
let limits = fast_limits();
let machine = spawn_duplex_machine(
stream,
Arc::clone(&refs),
Arc::clone(&ingest),
record.clone(),
limits,
false,
Arc::new(Semaphore::new(4)),
);
let packets = read_pkts(&mut client).await;
let lines = sideband_report_lines(&packets);
assert_eq!(
lines,
vec!["unpack ok".to_owned(), "ok refs/heads/main".to_owned()],
"the sideband-selected client rides band-1 with the self-framed report"
);
drop(client);
assert!(tokio::time::timeout(Duration::from_secs(5), machine)
.await
.unwrap()
.unwrap()
.is_ok());
}
#[tokio::test]
async fn the_stateless_machine_rejects_up_front_the_same_way() {
let record = repo("owner/doubled");
let mem_refs = MemRefs::default();
let refs = Arc::new(mem_refs.clone()) as Arc<dyn GitRefs>;
let ingest = Arc::new(MemPackIngest::default()) as Arc<dyn GitPackIngest>;
let (outcome, sink) = stateless_machine(
body_chunks(
"report-status",
&[
format!("shallow {}", oid(0x1b)),
create_command("refs/heads/main", oid(0x2c)),
],
Vec::new(),
),
Arc::clone(&refs),
Arc::clone(&ingest),
&record,
fast_limits(),
false,
)
.await;
let message = protocol_message(session_error(outcome));
assert!(message.contains("shallow"), "{message}");
assert!(sink.body().is_empty(), "no report on the rejected push");
let (outcome, sink) = stateless_machine(
body_chunks(
"object-format=sha1024",
&[create_command("refs/heads/main", oid(0x2c))],
Vec::new(),
),
Arc::clone(&refs),
Arc::clone(&ingest),
&record,
fast_limits(),
false,
)
.await;
let message = protocol_message(session_error(outcome));
assert!(message.contains("object-format"), "{message}");
assert!(sink.body().is_empty(), "no report on the rejected push");
assert_eq!(mem_refs.list_refs(&record).await.unwrap().len(), 0);
}
#[tokio::test]
async fn prepared_updates_are_the_cas_inputs() {
let record = repo("owner/doubled");
let chain = chain_pack();
let ingest = MemPackIngest::default();
let updates = vec![RefUpdate {
name: "refs/heads/main".to_owned(),
expected: None,
new: Some(chain.commit_b),
}];
let prepared = ingest
.prepare(
&record,
updates.clone(),
Box::new(io::Cursor::new(chain.pack)),
None,
Limits::default(),
)
.await
.unwrap();
assert!(prepared.unpack.is_ok());
assert_eq!(prepared.updates, updates, "verbatim into the CAS call");
}