crates/gunnar-pktline/src/read.rs
//! Reading pkt-lines off a blocking stream, **one exact line at a time**.
//!
//! # Why exact reads and not "read what is available"
//!
//! Two of the three protocols gunnar speaks stop being pkt-line framed part way
//! through a stream. `git-receive-pack` sends a framed command list, a flush-pkt,
//! and then **a raw packfile whose trailing hash covers every byte of it**; a
//! non-sideband fetch response does the same. A reader that pulled "whatever was
//! available" would swallow the head of the pack into its own buffer, and the
//! pack's checksum would then fail over bytes nobody can point at.
//!
//! [`read_packet`] therefore reads exactly four header bytes, then exactly the
//! payload the header declares, and leaves the stream positioned on the first
//! byte after the line. Nothing is buffered beyond one packet.
//!
//! # LAW 5: this used to be `gunnar-wire`'s private copy
//!
//! `gunnar_wire::wire_io::read_packet` is the same function. It is
//! `pub(crate)` there, and `gunnar-lfs` (spec §12) needs the identical reader
//! for the git-lfs pure-SSH transfer, which is pkt-line framed end to end. Two
//! copies of a framing reader is exactly the twinning LAW 5 forbids, and the
//! framing layer is where an off-by-four desyncs a stream and presents as a
//! network fault. It lives here, in the leaf crate both sides already depend
//! on, so there is one.
use std::io::BufRead;
use crate::{decode, Decoded, Error, Packet, Result};
/// Read exactly one pkt-line, or `None` at a **clean** end of stream.
///
/// `None` means the peer closed on a packet boundary with nothing pending, which
/// is a normal end for every framed section gunnar reads. A stream that ends
/// part-way through a header or a payload is a truncated peer, not a clean end,
/// and comes back as [`Error::Io`] with
/// [`std::io::ErrorKind::UnexpectedEof`].
pub fn read_packet(input: &mut dyn BufRead) -> Result<Option<Packet>> {
let mut head = [0u8; 4];
match read_up_to(input, &mut head)? {
0 => return Ok(None),
4 => {}
// Some bytes of a header arrived and then the stream ended. This is not
// a clean end: answering `None` here would report a truncated stream as
// a normal one, and the caller would treat a cut connection as a
// completed section.
_ => return Err(unexpected_eof("a pkt-line header")),
}
// `decode` validates the header (hex digits, the reserved `0003`, the
// length ceiling) and answers outright for the three control packets,
// whose whole line is those four bytes. `Incomplete` here therefore means
// "a valid header for a data packet whose payload has not arrived", which
// is the only case where the length has to be re-read below.
match decode(&head)? {
Decoded::Packet(pkt, _) => return Ok(Some(pkt)),
Decoded::Incomplete => {}
}
let len = line_length(&head);
let mut line = vec![0u8; len];
line[..4].copy_from_slice(&head);
input.read_exact(&mut line[4..]).map_err(|err| {
if err.kind() == std::io::ErrorKind::UnexpectedEof {
unexpected_eof("a pkt-line payload")
} else {
Error::Io(err)
}
})?;
match decode(&line)? {
Decoded::Packet(pkt, used) if used == len => Ok(Some(pkt)),
// Unreachable given `decode` accepted the header, but a silent
// `unwrap` here would turn a future decoder change into a corrupted
// stream rather than an error.
_ => Err(Error::BadLength(head)),
}
}
/// One pkt-line, refusing a clean end of stream.
///
/// `what` names the thing that was expected, so the diagnostic says *"expected a
/// batch argument"* rather than *"unexpected eof"*.
pub fn expect_packet(input: &mut dyn BufRead, what: &'static str) -> Result<Packet> {
match read_packet(input)? {
Some(pkt) => Ok(pkt),
None => Err(unexpected_eof(what)),
}
}
fn unexpected_eof(what: &'static str) -> Error {
Error::Io(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
format!("the stream ended while reading {what}"),
))
}
/// The declared total length of a line whose header `decode` already accepted.
///
/// Four hex digits, which [`decode`] has necessarily just parsed successfully:
/// it returned [`Decoded::Incomplete`] rather than an error. The parse is
/// repeated here only because `Decoded::Incomplete` carries no length.
fn line_length(head: &[u8; 4]) -> usize {
head.iter().fold(0usize, |acc, &b| {
let nibble = match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => 0,
};
(acc << 4) | nibble as usize
})
}
fn read_up_to(input: &mut dyn BufRead, buf: &mut [u8]) -> Result<usize> {
let mut filled = 0;
while filled < buf.len() {
match input.read(&mut buf[filled..])? {
0 => break,
n => filled += n,
}
}
Ok(filled)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn one(bytes: &[u8]) -> Option<Packet> {
let mut cursor = Cursor::new(bytes.to_vec());
let mut input: &mut dyn BufRead = &mut cursor;
read_packet(&mut input).expect("a well-formed line")
}
#[test]
fn reads_the_three_control_packets_and_a_data_line() {
assert_eq!(one(b"0000"), Some(Packet::Flush));
assert_eq!(one(b"0001"), Some(Packet::Delim));
assert_eq!(one(b"0002"), Some(Packet::ResponseEnd));
// `000a` and not `0009`: the length counts its own four header bytes,
// so a six-byte payload is `6 + 4`. Writing the payload length here is
// the off-by-four the crate docs call the most commonly mis-implemented
// detail of the format, and it would read `statu` and leave `s` on the
// stream.
assert_eq!(one(b"000astatus"), Some(Packet::Data(b"status".to_vec())));
assert_eq!(one(b"0004"), Some(Packet::Data(Vec::new())));
}
#[test]
fn an_empty_stream_is_a_clean_end_and_a_truncated_one_is_not() {
assert_eq!(one(b""), None);
// The red half. A three-byte header and a payload cut short must both
// be errors. If either answered `None`, a severed connection would be
// indistinguishable from a completed section, which is how a partial
// push gets reported as a successful one.
for truncated in [&b"000"[..], &b"0009stat"[..]] {
let mut cursor = Cursor::new(truncated.to_vec());
let mut input: &mut dyn BufRead = &mut cursor;
let err = read_packet(&mut input).expect_err("a truncated stream is not a clean end");
match err {
Error::Io(io) => assert_eq!(io.kind(), std::io::ErrorKind::UnexpectedEof),
other => panic!("expected an EOF error, got {other:?}"),
}
}
}
#[test]
fn the_stream_is_left_on_the_first_byte_after_the_line() {
// The property `receive-pack` depends on: nothing past the packet is
// consumed, so a raw packfile that follows arrives byte-exact.
let mut cursor = Cursor::new(b"000astatusPACK".to_vec());
let mut input: &mut dyn BufRead = &mut cursor;
assert_eq!(
read_packet(&mut input).unwrap(),
Some(Packet::Data(b"status".to_vec()))
);
let mut rest = Vec::new();
std::io::Read::read_to_end(&mut input, &mut rest).unwrap();
assert_eq!(rest, b"PACK");
}
#[test]
fn a_malformed_header_is_rejected_before_any_payload_is_awaited() {
// `ffff` declares more than MAX_PAYLOAD. It must be refused from the
// header alone: waiting for bytes that will never come would present a
// malformed frame as a hung connection.
let mut cursor = Cursor::new(b"ffff".to_vec());
let mut input: &mut dyn BufRead = &mut cursor;
assert!(matches!(
read_packet(&mut input),
Err(Error::TooLong(_)) | Err(Error::BadLength(_))
));
let mut cursor = Cursor::new(b"zzzz".to_vec());
let mut input: &mut dyn BufRead = &mut cursor;
assert!(matches!(read_packet(&mut input), Err(Error::BadLength(_))));
let mut cursor = Cursor::new(b"0003".to_vec());
let mut input: &mut dyn BufRead = &mut cursor;
assert!(matches!(
read_packet(&mut input),
Err(Error::ReservedLength)
));
}
#[test]
fn expect_packet_names_what_was_missing() {
let mut cursor = Cursor::new(Vec::new());
let mut input: &mut dyn BufRead = &mut cursor;
let err = expect_packet(&mut input, "a batch argument").expect_err("empty");
assert!(
err.to_string().contains("a batch argument"),
"the diagnostic must name what was expected, got {err}"
);
}
}