Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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}"
        );
    }
}