girt 0.6.4

An incremental, idiomatic Rust implementation of Git
Documentation
//! Shared pkt-line framing; service negotiation belongs to fetch and push.

#![warn(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::unreachable,
    clippy::indexing_slicing,
    reason = "parses untrusted repository or network input (#103)"
)]
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};

#[derive(Debug)]
pub(crate) enum Error {
    Io(std::io::Error),
    Protocol(&'static str),
    Limit(&'static str),
    Cancelled,
    Remote(Vec<u8>),
}
impl From<std::io::Error> for Error {
    fn from(error: std::io::Error) -> Self {
        Self::Io(error)
    }
}
pub(crate) fn check_cancelled(cancel: &AtomicBool) -> Result<(), Error> {
    if cancel.load(Ordering::Relaxed) {
        Err(Error::Cancelled)
    } else {
        Ok(())
    }
}
pub(crate) struct Wire<'a, R> {
    pub(crate) reader: &'a mut R,
    pub(crate) remaining: usize,
    pub(crate) cancel: &'a AtomicBool,
}
impl<R: Read> Wire<'_, R> {
    pub(crate) fn packet(&mut self) -> Result<Option<Vec<u8>>, Error> {
        self.charge(4)?;
        let mut header = [0; 4];
        self.exact(&mut header)?;
        let length = packet_length(&header)?;
        if length == 0 {
            return Ok(None);
        }
        self.charge(length - 4)?;
        let mut bytes = vec![0; length - 4];
        self.exact(&mut bytes)?;
        if let Some(message) = bytes.strip_prefix(b"ERR ") {
            return Err(Error::Remote(message.to_vec()));
        }
        Ok(Some(bytes))
    }
    fn charge(&mut self, bytes: usize) -> Result<(), Error> {
        self.remaining = self
            .remaining
            .checked_sub(bytes)
            .ok_or(Error::Limit("wire bytes"))?;
        Ok(())
    }
    fn exact(&mut self, mut bytes: &mut [u8]) -> Result<(), Error> {
        while !bytes.is_empty() {
            check_cancelled(self.cancel)?;
            let read = self.reader.read(bytes)?;
            if read == 0 {
                return Err(Error::Protocol("truncated pkt-line"));
            }
            bytes = bytes
                .get_mut(read..)
                .ok_or(Error::Protocol("reader overran its buffer"))?;
        }
        Ok(())
    }
    pub(crate) fn end(&mut self) -> Result<(), Error> {
        check_cancelled(self.cancel)?;
        if self.reader.read(&mut [0])? != 0 {
            return Err(Error::Protocol("trailing response bytes"));
        }
        Ok(())
    }
}

/// Decodes a complete header, including flush, using the shared pkt-line length bounds.
pub(crate) fn packet_length(header: &[u8; 4]) -> Result<usize, Error> {
    let length = std::str::from_utf8(header)
        .ok()
        .filter(|digits| digits.bytes().all(|byte| byte.is_ascii_hexdigit()))
        .and_then(|digits| usize::from_str_radix(digits, 16).ok())
        .ok_or(Error::Protocol("pkt-line header"))?;
    if length != 0 && !(4..=65520).contains(&length) {
        return Err(Error::Protocol("pkt-line length"));
    }
    Ok(length)
}

pub(crate) fn packet(
    writer: &mut impl Write,
    bytes: &[u8],
    cancel: &AtomicBool,
) -> Result<(), Error> {
    if bytes.len() > 65516 {
        return Err(Error::Protocol("pkt-line output length"));
    }
    put(
        writer,
        format!("{:04x}", bytes.len() + 4).as_bytes(),
        cancel,
    )?;
    put(writer, bytes, cancel)
}
pub(crate) fn put(
    writer: &mut impl Write,
    mut bytes: &[u8],
    cancel: &AtomicBool,
) -> Result<(), Error> {
    while !bytes.is_empty() {
        check_cancelled(cancel)?;
        let written = writer.write(bytes)?;
        if written == 0 {
            return Err(Error::Io(std::io::ErrorKind::WriteZero.into()));
        }
        bytes = bytes
            .get(written..)
            .ok_or(Error::Io(std::io::ErrorKind::InvalidData.into()))?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use rstest::rstest;

    use super::*;

    #[rstest]
    #[case::sign(b"+fff", "pkt-line header")]
    #[case::non_hex(b"zzzz", "pkt-line header")]
    #[case::non_ascii(b"\xff000", "pkt-line header")]
    #[case::too_short(b"0003", "pkt-line length")]
    #[case::too_long(b"fff1", "pkt-line length")]
    fn rejects_malformed_header(#[case] header: &[u8; 4], #[case] reason: &str) {
        assert!(matches!(packet_length(header), Err(Error::Protocol(actual)) if actual == reason));
    }

    #[rstest]
    #[case::flush(b"0000", 0)]
    #[case::empty(b"0004", 4)]
    #[case::upper(b"FFF0", 0xfff0)]
    fn decodes_valid_header(#[case] header: &[u8; 4], #[case] length: usize) {
        assert!(matches!(packet_length(header), Ok(actual) if actual == length));
    }
}