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

nordisk/gunnar

crates/gunnar-client/src/send_pack.rs

//! **send-pack**: the half of git that `gix` does not have.
//!
//! Verified in gix 0.86: `gix-protocol/src/` carries `fetch/`, `ls_refs.rs` and
//! `handshake/`, and **nothing for push**. `Direction::Push` exists only as a
//! refspec direction. So a git client written on gix has to put this on the
//! wire itself (the crate layout).
//!
//! # This module used to *be* the implementation. It is now a seam
//!
//! It was 816 lines of protocol here, and the four wire formats it spoke — the
//! capability list, the reference advertisement, the command list and the
//! status report — were **written a second time** in `gunnar-wire`, for the
//! server side of the identical conversation. Two implementations of one
//! grammar is the twinning one implementation forbids, and the design record
//! named it as a known divergence with the fix already named: *"the one
//! implementation goes in that crate and both directions route through it"*.
//!
//! That crate is [`gunnar_sendpack`] — donation-shaped, zero gunnar
//! dependencies, `MIT OR Apache-2.0`, liftable with `cp -r`. Everything below
//! is a re-export of it plus the one thing that cannot cross that boundary:
//! turning its error into this crate's.
//!
//! # The conversation, for readers who came here first
//!
//! ```text
//! S→C   <old-oid> <refname>\0<capabilities>\n     ref advertisement, protocol v0
//! S→C   0000
//! C→S   <old> <new> <refname>\0<capabilities>\n   the command list
//! C→S   0000
//! C→S   <push-option>\n … 0000                    only if `push-options` was negotiated
//! C→S   PACK…                                     raw, NOT pkt-line framed
//! S→C   unpack ok\n · ok <ref>\n | ng <ref> <why>\n · 0000
//! ```
//!
//! [`gunnar_sendpack::driver`] documents the three details implementations get
//! wrong, and carries a test for each.

pub use gunnar_sendpack::advertisement::{object_format_name, parse as parse_advertisement};
pub use gunnar_sendpack::command::{lines as command_lines, parse_line as parse_command_line};
pub use gunnar_sendpack::driver::{encode_command_list, encode_push_options};
pub use gunnar_sendpack::options::negotiate as negotiate_capabilities;
pub use gunnar_sendpack::report::parse as parse_report;
pub use gunnar_sendpack::{
    Advertisement, Capabilities, PushCommand, PushReport, RefStatus, RemoteRef, SendPackOptions,
};

use crate::error::{Error, Result};
use crate::transport::Transport;

impl From<gunnar_sendpack::Error> for Error {
    fn from(err: gunnar_sendpack::Error) -> Self {
        match err {
            gunnar_sendpack::Error::Io(io) => Error::Io(io),
            gunnar_sendpack::Error::Invalid(message) => Error::Invalid(message),
            // A hash-kind mismatch is a protocol error here rather than a
            // variant of its own: the client cannot do anything about it except
            // report it, and the CLI prints the message.
            other => Error::Protocol(other.to_string()),
        }
    }
}

/// This crate's [`Transport`] as [`gunnar_sendpack`]'s.
///
/// The two traits are the same trait — a blocking reader and a blocking writer,
/// handed out as a disjoint pair — declared twice because neither crate may
/// name the other's. The donation crate must name nothing of gunnar's,
/// and this crate's `Transport` is public API that `gunnar-cli`, `nornir-git`
/// and the SSH layer all implement.
///
/// So it is one adapter, in one place, rather than an `impl` per transport.
struct Bridge<'a, T: ?Sized>(&'a mut T);

impl<T: Transport + ?Sized> gunnar_sendpack::Transport for Bridge<'_, T> {
    fn io(&mut self) -> (&mut dyn std::io::Read, &mut dyn std::io::Write) {
        self.0.io()
    }
}

/// Read `git-receive-pack`'s ref advertisement from `reader`.
pub fn read_advertisement(reader: &mut dyn std::io::Read) -> Result<Advertisement> {
    Ok(gunnar_sendpack::read_advertisement(reader)?)
}

/// Read the report from `reader`, demultiplexing sidebands when negotiated.
pub fn read_report(reader: &mut dyn std::io::Read, side_band: bool) -> Result<PushReport> {
    Ok(gunnar_sendpack::driver::read_report(reader, side_band)?)
}

/// Run one complete send-pack over `transport`, given an advertisement already
/// read from it.
///
/// `write_pack` streams the packfile straight onto the wire; it is `None` for
/// an all-deletes push, which carries no pack. Splitting the advertisement out
/// of this call is what lets [`crate::push`] decide *what* to push after seeing
/// what the remote has; that decision is porcelain's, not the wire's.
pub fn send_pack<T, P>(
    transport: &mut T,
    advertisement: &Advertisement,
    commands: &[PushCommand],
    local_hash_kind: gix_hash::Kind,
    write_pack: Option<P>,
    opts: &SendPackOptions,
) -> Result<PushReport>
where
    T: Transport,
    P: FnOnce(&mut dyn std::io::Write) -> Result<()>,
{
    let mut bridge = Bridge(transport);
    // The pack writer's error type crosses the boundary the other way: the
    // caller's closure fails with *this* crate's `Error` (it writes a
    // `gunnar-pack` packfile), and the donation crate knows only its own. Both
    // sides are `io::Error`-shaped for anything the wire can cause, so the
    // conversion is lossless for the failures that reach the socket and
    // preserves the message for the rest — the whole chain, not the outer
    // word alone (it read "invalid argument: packfile").
    let pack_failed = std::rc::Rc::new(std::cell::Cell::new(false));
    let flag = std::rc::Rc::clone(&pack_failed);
    let write_pack = write_pack.map(|f| {
        move |w: &mut dyn std::io::Write| -> gunnar_sendpack::Result<()> {
            f(w).map_err(|e| {
                flag.set(true);
                match e {
                    Error::Io(io) => gunnar_sendpack::Error::Io(io),
                    other => gunnar_sendpack::Error::invalid(crate::error::chain(&other)),
                }
            })
        }
    });
    match gunnar_sendpack::send_pack(
        &mut bridge,
        advertisement,
        commands,
        local_hash_kind,
        write_pack,
        opts,
    ) {
        Ok(report) => Ok(report),
        // ★ **The remote stopped reading the pack because it REFUSED it**
        // (round 5.3.1; the r5.1 silent-twin fill 2026-10-08: 56 pushes ended
        // "invalid argument: packfile" while main had refused each for room
        // mid-receive — "the data set is full right now: … does not fit beside
        // the pushes already arriving" — and written that as its `unpack`
        // line). When the pack could not be written, the remote's report is
        // read before the error is believed: a refusal in the remote's own
        // words is the answer; with no report, the write error stands.
        Err(err) if pack_failed.get() => {
            match refused_midstream(&mut bridge, advertisement, commands, local_hash_kind, opts) {
                Some(report) => Ok(report),
                None => Err(err.into()),
            }
        }
        Err(err) => Err(err.into()),
    }
}

/// The remote's report after a pack it stopped reading, when it sent one that
/// refuses the push (`unpack` not `ok`) — reconciled with the commands, as a
/// completed send-pack's is. `None` when there is no such report to read.
fn refused_midstream<T: gunnar_sendpack::Transport>(
    transport: &mut T,
    advertisement: &Advertisement,
    commands: &[PushCommand],
    local_hash_kind: gix_hash::Kind,
    opts: &SendPackOptions,
) -> Option<PushReport> {
    let caps =
        gunnar_sendpack::options::negotiate(advertisement, commands, local_hash_kind, opts).ok()?;
    if !caps
        .iter()
        .any(|c| c == "report-status" || c == "report-status-v2")
    {
        return None;
    }
    let side_band = caps.iter().any(|c| c == "side-band-64k");
    let (reader, _) = transport.io();
    let mut report = gunnar_sendpack::driver::read_report(reader, side_band).ok()?;
    // A remote that sent nothing (the stream simply ended) parses to an empty
    // `unpack`: that is no report, and the write error stands.
    if report.unpack.trim().is_empty() || report.unpack == "ok" {
        return None;
    }
    let commanded: Vec<_> = commands.iter().map(|c| c.name.clone()).collect();
    report.reconcile(&commanded);
    Some(report)
}

/// The `agent=` string this client identifies itself with, on the wire.
///
/// [`SendPackOptions::default`] in the donation crate names *that* crate; a
/// gunnar client says `gunnar/<version>`, which is what the remote's logs and
/// the interop tests expect.
pub fn options() -> SendPackOptions {
    SendPackOptions {
        agent: crate::agent(),
        ..SendPackOptions::default()
    }
}

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

    /// A remote that reads `take` bytes of the pack, then stops reading (the
    /// write fails with EINVAL, as the silent-twin's pushes did).
    struct Refusing {
        took: usize,
        take: usize,
    }
    impl std::io::Write for Refusing {
        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
            if self.took >= self.take {
                return Err(std::io::Error::from_raw_os_error(22));
            }
            let n = buf.len().min(self.take - self.took);
            self.took += n;
            Ok(n)
        }
        fn flush(&mut self) -> std::io::Result<()> {
            Ok(())
        }
    }
    fn remote(report: Vec<u8>) -> crate::transport::IoPair<std::io::Cursor<Vec<u8>>, Refusing> {
        crate::transport::IoPair {
            reader: std::io::Cursor::new(report),
            writer: Refusing { took: 0, take: 300 },
        }
    }
    fn pkt(line: &str) -> Vec<u8> {
        let mut v = format!("{:04x}", line.len() + 4).into_bytes();
        v.extend_from_slice(line.as_bytes());
        v
    }

    /// ★ **A pack the remote refused mid-stream reads as the remote's refusal**
    /// (the r5.1 silent-twin fill, 2026-10-08: 56 pushes said "invalid
    /// argument: packfile" while main had refused each for room and written
    /// why). The write fails, the remote's report is read, and the push answers
    /// with its `unpack` line — not the write error.
    #[test]
    fn a_pack_refused_mid_stream_reads_as_the_remotes_refusal() {
        let why = "the data set is full right now: this push does not fit beside the pushes already arriving";
        let mut report = pkt(&format!("unpack {why}\n"));
        report.extend(pkt("ng refs/heads/main unpacker error\n"));
        report.extend_from_slice(b"0000");
        let zero = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
        let adv = Advertisement {
            refs: vec![],
            capabilities: Capabilities::parse("report-status"),
            hash_kind: gix_hash::Kind::Sha1,
        };
        let new =
            gix_hash::ObjectId::from_hex(b"1111111111111111111111111111111111111111").unwrap();
        let commands = vec![PushCommand {
            name: "refs/heads/main".into(),
            old: zero,
            new,
        }];
        let mut refusing = remote(report);
        let pack = |w: &mut dyn std::io::Write| -> Result<()> {
            for _ in 0..100 {
                w.write_all(&[7u8; 1024]).map_err(Error::Io)?;
            }
            Ok(())
        };
        let got = send_pack(
            &mut refusing,
            &adv,
            &commands,
            gix_hash::Kind::Sha1,
            Some(pack),
            &SendPackOptions::default(),
        )
        .expect("the remote's report is the answer");
        assert_eq!(got.unpack, why, "the remote's own words");
        assert!(
            got.failure_summary()
                .is_some_and(|w| w.contains("does not fit beside")),
            "{:?}",
            got.failure_summary()
        );

        // No report to read: the write error stands, with its cause in the chain.
        let mut silent = remote(Vec::new());
        let err = send_pack(
            &mut silent,
            &adv,
            &commands,
            gix_hash::Kind::Sha1,
            Some(pack),
            &SendPackOptions::default(),
        )
        .expect_err("nothing to read");
        assert!(
            crate::error::chain(&err).contains("os error 22")
                || crate::error::chain(&err)
                    .to_lowercase()
                    .contains("invalid argument"),
            "{}",
            crate::error::chain(&err)
        );
    }

    /// The one behaviour that lives here rather than in the donation crate:
    /// gunnar's own `agent=`. Seen red by asserting the string, since the
    /// crate's own default is `gunnar-sendpack/…` and would otherwise pass a
    /// `starts_with("gunnar")` check.
    #[test]
    fn the_agent_identifies_gunnar_and_not_the_donation_crate() {
        let agent = options().agent;
        assert!(
            agent.starts_with("gunnar/"),
            "the wire must say `gunnar/<version>`, not {agent:?}"
        );
        assert!(!agent.starts_with("gunnar-sendpack"));
    }

    /// The bridge is not a formality: without it nothing in this crate can be
    /// handed to the driver at all. Asserted by driving a push through a pair
    /// of in-memory buffers, i.e. through `crate::transport::IoPair`.
    #[test]
    fn this_crates_transport_reaches_the_driver() {
        let adv = parse_advertisement(&[
            b"1111111111111111111111111111111111111111 refs/heads/main\0delete-refs \
              object-format=sha1\n"
                .to_vec(),
        ])
        .unwrap();
        let mut t = crate::transport::IoPair::new(std::io::Cursor::new(Vec::new()), Vec::new());
        let report = send_pack(
            &mut t,
            &adv,
            &[PushCommand::delete(
                "refs/heads/main",
                gix_hash::ObjectId::from_hex(b"1111111111111111111111111111111111111111").unwrap(),
            )],
            gix_hash::Kind::Sha1,
            None::<fn(&mut dyn std::io::Write) -> Result<()>>,
            &options(),
        )
        .expect("a remote offering no report-status is legal");
        assert_eq!(report.unpack, "ok");
        assert!(
            !t.writer.is_empty(),
            "the command list never reached the transport"
        );
    }
}