use std::io::{BufRead, Write};
use crate::pack::IndexedPack;
use gix_hash::ObjectId;
use gunnar_pktline::{Packet, BAND_PACK};
use gunnar_store::git_storage_trait::{GitServe, Observed, RefCas, RefRejection, RefTarget};
use crate::caps::{self, PeerCapabilities};
use crate::graph::{verify_connectivity, Frontier, Graph};
use crate::refs::RefSnapshot;
use crate::wire_io::{self, read_packet, read_section};
use crate::{Error, Result, Session};
pub const GIT_TARGET: &str = "gunnar::git";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefCommand {
pub old: ObjectId,
pub new: ObjectId,
pub name: String,
}
impl RefCommand {
pub fn is_delete(&self) -> bool {
self.new.is_null()
}
pub fn is_create(&self) -> bool {
self.old.is_null()
}
}
#[derive(Debug)]
pub struct Preamble {
pub commands: Vec<RefCommand>,
pub unnameable: Vec<UnnameableRef>,
pub peer: PeerCapabilities,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnnameableRef {
pub name: Vec<u8>,
pub reason: String,
pub position: usize,
pub deletes: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefStatus {
Ok,
Rejected(String),
}
impl RefStatus {
pub fn is_ok(&self) -> bool {
matches!(self, RefStatus::Ok)
}
}
pub struct PushFacts<'a> {
pub objects: &'a dyn GitServe,
pub new_objects: std::result::Result<&'a [ObjectId], &'a str>,
pub pack_bytes: Option<u64>,
pub push_options: &'a [String],
}
#[derive(Debug, Clone)]
pub struct PackReceipt {
pub indexed: IndexedPack,
pub new_objects: std::result::Result<Vec<ObjectId>, String>,
}
impl PackReceipt {
pub fn without_object_set(indexed: IndexedPack) -> Self {
PackReceipt {
indexed,
new_objects: Err(
"this PackIngest does not report which objects the push introduced".to_owned(),
),
}
}
}
pub trait PushGate {
fn check(&self, command: &RefCommand, facts: &PushFacts<'_>)
-> std::result::Result<(), String>;
}
pub struct AllowAll;
impl PushGate for AllowAll {
fn check(
&self,
_command: &RefCommand,
_facts: &PushFacts<'_>,
) -> std::result::Result<(), String> {
Ok(())
}
}
pub trait PackIngest {
fn ingest(&self, pack: &mut dyn BufRead) -> Result<PackReceipt>;
}
impl<F> PackIngest for F
where
F: Fn(&mut dyn BufRead) -> Result<IndexedPack>,
{
fn ingest(&self, pack: &mut dyn BufRead) -> Result<PackReceipt> {
self(pack).map(PackReceipt::without_object_set)
}
}
pub(crate) struct Counting<'a> {
inner: &'a mut dyn BufRead,
pub(crate) bytes: u64,
ceiling: Option<PackCeiling>,
charged_ahead: u64,
pub(crate) cut: bool,
}
impl<'a> Counting<'a> {
pub(crate) fn new(inner: &'a mut dyn BufRead, ceiling: Option<PackCeiling>) -> Self {
Counting {
inner,
bytes: 0,
ceiling,
charged_ahead: 0,
cut: false,
}
}
}
impl Counting<'_> {
fn over(&self) -> bool {
matches!(&self.ceiling, Some(ceiling) if self.bytes > ceiling.bytes)
}
fn room(&self) -> Option<usize> {
let ceiling = self.ceiling.as_ref()?.bytes;
let room = ceiling.saturating_sub(self.bytes).saturating_add(1);
Some(usize::try_from(room).unwrap_or(usize::MAX))
}
fn admit(&mut self, want: usize) -> usize {
let Some(ledger) = self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) else {
return want;
};
let room = self
.ceiling
.as_ref()
.map(|c| c.bytes.saturating_add(1))
.unwrap_or(u64::MAX);
let want = want as u64;
let owing = want.saturating_sub(self.charged_ahead);
if owing > 0 {
self.charged_ahead = self
.charged_ahead
.saturating_add(ledger.admit_upto(owing, room));
}
usize::try_from(self.charged_ahead.min(want)).unwrap_or(usize::MAX)
}
fn refuse(&mut self) -> std::io::Error {
self.cut = true;
let ceiling = self.ceiling.as_ref().map(|c| c.bytes).unwrap_or_default();
std::io::Error::other(format!(
"the push passed its {ceiling}-byte ceiling after {} byte(s); the pack read was \
aborted mid-transfer and nothing was stored",
self.bytes
))
}
}
impl std::io::Read for Counting<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.cut || self.over() {
let err = self.refuse();
return Err(err);
}
let available = match self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) {
Some(_) => {
let available = self.inner.fill_buf()?.len();
if available == 0 {
return Ok(0);
}
available
}
None => usize::MAX,
};
let want = match self.room() {
Some(room) => room.min(buf.len()).min(available),
None => buf.len().min(available),
};
let allowed = self.admit(want);
if allowed == 0 && !buf.is_empty() {
let err = self.refuse();
return Err(err);
}
let read = self.inner.read(&mut buf[..allowed])?;
self.bytes += read as u64;
self.charged_ahead = self.charged_ahead.saturating_sub(read as u64);
Ok(read)
}
}
impl BufRead for Counting<'_> {
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
if self.cut || self.over() {
let err = self.refuse();
return Err(err);
}
let room = self.room();
let allowed = match self.ceiling.as_ref().and_then(|c| c.ledger.as_ref()) {
Some(_) => {
let available = self.inner.fill_buf()?.len();
let want = match room {
Some(room) if available > room => room,
_ => available,
};
let allowed = self.admit(want);
if allowed == 0 && available > 0 {
let err = self.refuse();
return Err(err);
}
Some(allowed)
}
None => None,
};
let filled = self.inner.fill_buf()?;
Ok(match (allowed, room) {
(Some(allowed), _) => &filled[..allowed.min(filled.len())],
(None, Some(room)) if filled.len() > room => &filled[..room],
_ => filled,
})
}
fn consume(&mut self, amount: usize) {
self.inner.consume(amount);
self.bytes += amount as u64;
self.charged_ahead = self.charged_ahead.saturating_sub(amount as u64);
}
}
#[derive(Debug)]
pub struct PushOutcome {
pub commands: Vec<RefCommand>,
pub statuses: Vec<RefStatus>,
pub unnameable: Vec<UnnameableRef>,
pub push_options: Vec<String>,
pub pack: Option<IndexedPack>,
pub pack_bytes: Option<u64>,
pub new_objects: std::result::Result<Vec<ObjectId>, String>,
pub atomic: bool,
pub unpack: std::result::Result<(), String>,
}
impl PushOutcome {
pub fn all_ok(&self) -> bool {
self.unpack.is_ok()
&& self.unnameable.is_empty()
&& self.statuses.iter().all(RefStatus::is_ok)
}
}
#[derive(Debug, Clone)]
pub struct PackCeiling {
pub bytes: u64,
pub refusal: String,
pub ledger: Option<std::sync::Arc<dyn PackLedger>>,
}
pub trait PackLedger: Send + Sync + std::fmt::Debug {
fn admit_upto(&self, want: u64, room: u64) -> u64;
}
#[derive(Debug, Clone)]
pub struct Config {
pub agent: String,
pub budget: Option<usize>,
pub notice: Option<String>,
pub pack_ceiling: Option<PackCeiling>,
}
impl Default for Config {
fn default() -> Self {
Config {
agent: caps::default_agent(),
budget: Some(crate::graph::CONNECTIVITY_BUDGET),
notice: None,
pack_ceiling: None,
}
}
}
pub struct ReceivePack<'a> {
git: &'a dyn GitServe,
hash_kind: gix_hash::Kind,
config: Config,
}
fn render(o: &Observed) -> String {
match o {
Observed::Nothing => "0000000000000000000000000000000000000000".to_owned(),
Observed::Value(RefTarget::Object(oid)) => {
oid.iter()
.fold(String::with_capacity(oid.len() * 2), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "{b:02x}");
acc
})
}
Observed::Value(RefTarget::Symbolic(name)) => format!("ref: {name}"),
Observed::Unreadable(what) => format!("unreadable ({what})"),
}
}
impl<'a> ReceivePack<'a> {
pub fn new(git: &'a dyn GitServe, hash_kind: gix_hash::Kind) -> Self {
ReceivePack {
git,
hash_kind,
config: Config::default(),
}
}
pub fn with_config(mut self, config: Config) -> Self {
self.config = config;
self
}
pub fn hash_kind(&self) -> gix_hash::Kind {
self.hash_kind
}
pub fn write_ref_advertisement(&self, out: &mut dyn Write) -> Result<()> {
let capabilities = caps::receive_pack_capabilities(self.hash_kind(), &self.config.agent);
let snapshot = RefSnapshot::read(self.git)?;
let mut refs = Vec::new();
for row in snapshot.rows() {
let Some(id) = snapshot.resolve_row(row)? else {
continue;
};
refs.push(gunnar_sendpack::RemoteRef::new(row.name.as_bytes(), id));
}
for line in gunnar_sendpack::advertisement::lines(&refs, &capabilities, self.hash_kind()) {
wire_io::text(out, &line)?;
}
wire_io::flush(out)?;
Ok(())
}
pub fn serve(
&self,
input: &mut dyn BufRead,
out: &mut dyn Write,
ingest: &dyn PackIngest,
gate: &dyn PushGate,
) -> Result<Option<PushOutcome>> {
self.serve_session(Session::Connected, input, out, ingest, gate)
}
pub fn serve_session(
&self,
session: Session,
input: &mut dyn BufRead,
out: &mut dyn Write,
ingest: &dyn PackIngest,
gate: &dyn PushGate,
) -> Result<Option<PushOutcome>> {
if session.advertises_inline() {
let _s = crate::stage::enter("advertise");
self.write_ref_advertisement(out)?;
out.flush()?;
}
let Some(preamble) = Self::read_preamble(self.hash_kind(), input)? else {
return Ok(None);
};
self.serve_after_preamble(preamble, input, out, ingest, gate)
.map(Some)
}
pub fn write_empty_advertisement(
hash_kind: gix_hash::Kind,
config: &Config,
out: &mut dyn Write,
) -> Result<()> {
let capabilities = caps::receive_pack_capabilities(hash_kind, &config.agent);
for line in gunnar_sendpack::advertisement::lines(&[], &capabilities, hash_kind) {
wire_io::text(out, &line)?;
}
wire_io::flush(out)?;
out.flush()?;
Ok(())
}
pub fn read_preamble(
hash_kind: gix_hash::Kind,
input: &mut dyn BufRead,
) -> Result<Option<Preamble>> {
let (commands, unnameable, peer) = {
let _s = crate::stage::enter("command_read");
Self::read_commands(hash_kind, input)?
};
if commands.is_empty() && unnameable.is_empty() {
tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.no_commands",
"receive-pack: the client sent no ref commands"
);
return Ok(None);
}
caps::check_object_format(hash_kind, peer.value("object-format"))?;
Ok(Some(Preamble {
commands,
unnameable,
peer,
}))
}
pub fn serve_after_preamble(
&self,
preamble: Preamble,
input: &mut dyn BufRead,
out: &mut dyn Write,
ingest: &dyn PackIngest,
gate: &dyn PushGate,
) -> Result<PushOutcome> {
let Preamble {
commands,
unnameable,
peer,
} = preamble;
let _s_opts = crate::stage::enter("push_options");
let push_options = if peer.has("push-options") {
let mut budget =
crate::limits::Budget::new(Some(crate::limits::DEFAULT_REQUEST_LIMIT_BYTES));
read_section(input, "the push-options section", &mut budget)?
.into_iter()
.map(|line| String::from_utf8_lossy(&line).into_owned())
.collect()
} else {
Vec::new()
};
crate::stage::end(_s_opts);
let sideband = peer.has("side-band-64k");
let report_requested = peer.has("report-status") || peer.has("report-status-v2");
let atomic = peer.has("atomic");
let expects_pack = commands.iter().any(|command| !command.is_delete())
|| unnameable.iter().any(|refused| !refused.deletes);
tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.commands",
commands = commands.len(),
deletes = commands.iter().filter(|c| c.is_delete()).count(),
push_options = push_options.len(),
atomic,
expects_pack,
"receive-pack: read the ref command list"
);
let mut pack = None;
let mut pack_bytes = Some(0u64);
let mut new_objects: std::result::Result<Vec<ObjectId>, String> = Ok(Vec::new());
let mut unpack: std::result::Result<(), String> = Ok(());
if expects_pack {
let _s = crate::stage::enter("pack_ingest");
tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.receiving",
"receiving and indexing the pushed pack"
);
let started = std::time::Instant::now();
let mut counting = Counting {
inner: input,
bytes: 0,
ceiling: self.config.pack_ceiling.clone(),
charged_ahead: 0,
cut: false,
};
let received = ingest.ingest(&mut counting);
pack_bytes = Some(counting.bytes);
let cut = counting.cut;
match received {
Ok(receipt) => {
tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.indexed",
objects = receipt.indexed.num_objects,
pack = %receipt.indexed.pack_id,
pack_bytes = counting.bytes,
elapsed_ms = started.elapsed().as_millis() as u64,
"indexed a pushed pack"
);
new_objects = receipt.new_objects;
pack = Some(receipt.indexed);
}
Err(err) => {
let message = err.client_message();
tracing::warn!(
target: GIT_TARGET,
event = "git.receive_pack.index_failed",
pack_bytes = counting.bytes,
elapsed_ms = started.elapsed().as_millis() as u64,
error = %err,
client_message = %message,
"the pushed pack did not index"
);
unpack = Err(message);
}
}
if cut {
let line = self
.config
.pack_ceiling
.as_ref()
.map(|ceiling| ceiling.refusal.clone())
.unwrap_or_else(|| {
"the push was cut at its byte ceiling".to_owned()
});
tracing::warn!(
target: GIT_TARGET,
event = "git.receive_pack.ceiling",
pack_bytes = counting.bytes,
ceiling = self.config.pack_ceiling.as_ref().map(|c| c.bytes),
refusal = %line,
"the pushed pack passed its byte ceiling and the transfer was aborted \
mid-stream; nothing was indexed and no reference was moved"
);
pack = None;
new_objects = Err(
"the push was cut at its byte ceiling before the pack was indexed"
.to_owned(),
);
unpack = Err(line);
}
}
let statuses = if unpack.is_err() {
vec![RefStatus::Rejected("unpacker error".to_owned()); commands.len()]
} else {
let facts = PushFacts {
objects: self.git,
new_objects: new_objects.as_deref().map_err(String::as_str),
pack_bytes,
push_options: &push_options,
};
self.apply(&commands, atomic, !unnameable.is_empty(), gate, &facts)?
};
let outcome = PushOutcome {
commands,
statuses,
unnameable,
push_options,
pack,
pack_bytes,
new_objects,
atomic,
unpack,
};
if report_requested {
let _s = crate::stage::enter("report");
self.write_report(out, &outcome, sideband)?;
out.flush()?;
}
Ok(outcome)
}
fn read_commands(
hash_kind: gix_hash::Kind,
input: &mut dyn BufRead,
) -> Result<(Vec<RefCommand>, Vec<UnnameableRef>, PeerCapabilities)> {
let mut commands = Vec::new();
let mut unnameable = Vec::new();
let mut peer = PeerCapabilities::default();
let mut first = true;
let mut position = 0usize;
loop {
let packet = match read_packet(input)? {
None => {
if first {
return Ok((commands, unnameable, peer));
}
return Err(Error::UnexpectedEof("the push command list"));
}
Some(packet) => packet,
};
match packet {
Packet::Flush => break,
Packet::Data(data) => {
let mut text: &[u8] = data.strip_suffix(b"\n").unwrap_or(&data);
if text.starts_with(b"shallow ") {
continue;
}
if first {
if let Some(nul) = text.iter().position(|&b| b == 0) {
peer =
PeerCapabilities::parse(&String::from_utf8_lossy(&text[nul + 1..]));
text = &text[..nul];
}
first = false;
}
match Self::parse_command(hash_kind, text, position)? {
Ok(command) => commands.push(command),
Err(refused) => unnameable.push(refused),
}
position += 1;
}
other => {
return Err(Error::protocol(format!(
"unexpected {other:?} in the push command list"
)))
}
}
}
Ok((commands, unnameable, peer))
}
fn parse_command(
hash_kind: gix_hash::Kind,
line: &[u8],
position: usize,
) -> Result<std::result::Result<RefCommand, UnnameableRef>> {
let command =
gunnar_sendpack::command::parse_line(line, hash_kind).map_err(|err| match err {
gunnar_sendpack::Error::ObjectFormat { ours, theirs } => {
Error::ObjectFormatMismatch {
ours,
theirs: theirs.to_owned(),
}
}
other => Error::protocol(other.to_string()),
})?;
let name = match validate_ref_name(command.name.as_slice()) {
Ok(name) => name,
Err(err) => {
tracing::warn!(
target: GIT_TARGET,
event = "git.receive_pack.unnameable_ref",
ref_name = %String::from_utf8_lossy(&command.name),
error = %err,
"refusing one reference for its name; the rest of the push continues"
);
return Ok(Err(UnnameableRef {
name: command.name.to_vec(),
reason: format!("funny refname: {err}"),
position,
deletes: command.new.is_null(),
}));
}
};
Ok(Ok(RefCommand {
old: command.old,
new: command.new,
name,
}))
}
fn apply(
&self,
commands: &[RefCommand],
atomic: bool,
already_refused: bool,
gate: &dyn PushGate,
facts: &PushFacts<'_>,
) -> Result<Vec<RefStatus>> {
let mut statuses = vec![RefStatus::Ok; commands.len()];
let _conn = crate::stage::enter("connectivity");
tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.checking",
commands = commands.len(),
"checking that the push closes, before any reference moves"
);
let started = std::time::Instant::now();
match self.git.index_now() {
Ok(0) => {}
Ok(packs) => tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.indexed_for_check",
packs = packs,
elapsed_ms = started.elapsed().as_millis() as u64,
"finished the deferred index before checking that the push closes"
),
Err(err) => {
tracing::warn!(
target: GIT_TARGET,
event = "git.receive_pack.index_for_check_failed",
commands = commands.len(),
elapsed_ms = started.elapsed().as_millis() as u64,
error = %format_args!("{err:#}"),
"the deferred index did not finish, so the push cannot be checked and is \
refused; the pack's bytes are durable and nothing partial was indexed"
);
let reason = format!("the push cannot be checked: {}", err.root_cause());
return Ok(commands
.iter()
.map(|command| {
if command.is_delete() {
RefStatus::Ok
} else {
RefStatus::Rejected(reason.clone())
}
})
.collect());
}
}
let snapshot = RefSnapshot::read(self.git)?;
let mut frontier_values: Vec<ObjectId> = Vec::with_capacity(snapshot.rows().len());
for row in snapshot.rows() {
if let Some(bytes) = &row.oid {
frontier_values.push(crate::graph::oid(bytes)?);
}
}
let graph = Graph::new(self.git, self.hash_kind).with_budget(self.config.budget);
let frontier = Frontier::new(&graph, &frontier_values)?;
let new_tips: Vec<ObjectId> = commands
.iter()
.filter(|command| !command.is_delete())
.map(|command| command.new)
.collect();
let batch = if new_tips.is_empty() {
Ok(0)
} else {
verify_connectivity(&graph, &new_tips, &frontier)
};
match &batch {
Ok(objects) => tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.checked",
tips = new_tips.len(),
objects = *objects,
elapsed_ms = started.elapsed().as_millis() as u64,
"connectivity verified"
),
Err(err) => tracing::warn!(
target: GIT_TARGET,
event = "git.receive_pack.check_failed",
tips = new_tips.len(),
elapsed_ms = started.elapsed().as_millis() as u64,
error = %err,
"the push does not close; re-walking one reference at a time to say which"
),
}
for (index, command) in commands.iter().enumerate() {
if !command.is_delete() && batch.is_err() {
if let Err(err) = verify_connectivity(&graph, &[command.new], &frontier) {
tracing::warn!(target: GIT_TARGET, ref_name = %command.name, error = %err, "push failed connectivity");
statuses[index] =
RefStatus::Rejected(format!("missing necessary objects: {err}"));
continue;
}
}
if let Err(reason) = gate.check(command, facts) {
statuses[index] = RefStatus::Rejected(reason);
}
}
crate::stage::end(_conn);
if atomic && (already_refused || statuses.iter().any(|s| !s.is_ok())) {
for status in statuses.iter_mut() {
if status.is_ok() {
*status = RefStatus::Rejected("atomic transaction failed".to_owned());
}
}
return Ok(statuses);
}
let edits: Vec<(usize, RefCas<'_>)> = commands
.iter()
.enumerate()
.filter(|(index, _)| statuses[*index].is_ok())
.map(|(index, command)| Ok((index, edit_for(command, &graph)?)))
.collect::<Result<_>>()?;
let _txn = crate::stage::enter("ref_txn");
tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.updating",
edits = edits.len(),
atomic,
"applying the reference updates"
);
let started = std::time::Instant::now();
if atomic {
let batch: Vec<RefCas<'_>> = edits
.iter()
.map(|(_, edit)| RefCas {
name: edit.name.clone(),
old: edit.old,
new: edit.new,
peeled: edit.peeled.clone(),
})
.collect();
if let Err(err) = self.git.put_refs_cas(&batch) {
let reason = format!("atomic transaction failed: {err:#}");
for (index, _) in &edits {
statuses[*index] = RefStatus::Rejected(reason.clone());
}
}
} else {
for (index, edit) in edits {
if let Err(err) = self.git.put_refs_cas(std::slice::from_ref(&edit)) {
statuses[index] = match err.downcast_ref::<RefRejection>() {
Some(RefRejection::Cas {
name,
expected,
actual,
}) => RefStatus::Rejected(format!(
"cannot lock ref '{name}': is at {} but expected {}",
render(actual),
render(expected)
)),
Some(RefRejection::Locked { name }) => RefStatus::Rejected(format!(
"cannot lock ref '{name}': held by another writer, try again"
)),
_ => RefStatus::Rejected(format!("{err:#}")),
};
}
}
}
tracing::info!(
target: GIT_TARGET,
event = "git.receive_pack.updated",
refs_ok = statuses.iter().filter(|s| s.is_ok()).count(),
refs_rejected = statuses.iter().filter(|s| !s.is_ok()).count(),
elapsed_ms = started.elapsed().as_millis() as u64,
"the reference updates are applied"
);
Ok(statuses)
}
fn write_report(
&self,
out: &mut dyn Write,
outcome: &PushOutcome,
sideband: bool,
) -> Result<()> {
let mut statuses: Vec<(usize, gunnar_sendpack::RefStatus)> = Vec::new();
let taken: std::collections::BTreeSet<usize> =
outcome.unnameable.iter().map(|r| r.position).collect();
let mut free = (0..).filter(|position| !taken.contains(position));
for (command, status) in outcome.commands.iter().zip(&outcome.statuses) {
let position = free.next().expect("the position sequence is infinite");
statuses.push((
position,
match status {
RefStatus::Ok => gunnar_sendpack::RefStatus::ok(command.name.as_bytes()),
RefStatus::Rejected(reason) => {
gunnar_sendpack::RefStatus::rejected(command.name.as_bytes(), reason)
}
},
));
}
for refused in &outcome.unnameable {
statuses.push((
refused.position,
gunnar_sendpack::RefStatus::rejected(
echoable_name(&refused.name, refused.reason.len()),
&refused.reason,
),
));
}
statuses.sort_by_key(|(position, _)| *position);
let statuses: Vec<gunnar_sendpack::RefStatus> =
statuses.into_iter().map(|(_, status)| status).collect();
let mut report = Vec::new();
for line in gunnar_sendpack::report::lines(
outcome.unpack.as_ref().copied().map_err(String::as_str),
&statuses,
) {
gunnar_pktline::write_text(&mut report, &line)?;
}
gunnar_pktline::write_flush(&mut report)?;
if sideband {
if let Some(notice) = &self.config.notice {
let mut line = notice.clone();
if !line.ends_with('\n') {
line.push('\n');
}
wire_io::progress(out, true, &line)?;
}
wire_io::sideband(out, BAND_PACK, &report)?;
wire_io::flush(out)?;
} else {
out.write_all(&report)?;
}
Ok(())
}
}
fn echoable_name(name: &[u8], reason_len: usize) -> Vec<u8> {
const MARKER: &[u8] = b"\xe2\x80\xa6[truncated]";
let overhead = 3 + 1 + reason_len + 1;
let budget = gunnar_pktline::MAX_PAYLOAD.saturating_sub(overhead);
if name.len() <= budget {
return name.to_vec();
}
let keep = budget.saturating_sub(MARKER.len());
let mut out = name[..keep].to_vec();
out.extend_from_slice(MARKER);
out
}
fn validate_ref_name(name: &[u8]) -> std::result::Result<String, String> {
gunnar_store::refname::validate(name).map_err(|err| match err {
gunnar_store::Error::InvalidRefName { reason, .. } => reason.to_owned(),
other => other.to_string(),
})?;
String::from_utf8(name.to_vec())
.map_err(|_| "a reference name gunnar can store must be UTF-8".to_owned())
}
fn edit_for<'a>(command: &'a RefCommand, graph: &Graph<'_>) -> Result<RefCas<'a>> {
let peeled = if command.is_delete() {
None
} else {
let peeled = graph.peel(&command.new)?;
(!peeled.tags.is_empty()).then(|| peeled.id.as_bytes().to_vec())
};
Ok(RefCas {
name: command.name.clone(),
old: (!command.is_create()).then(|| command.old.as_bytes()),
new: (!command.is_delete()).then(|| command.new.as_bytes()),
peeled,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_refused_name_too_long_to_echo_is_truncated_rather_than_costing_the_report() {
let name = vec![b'x'; gunnar_pktline::MAX_PAYLOAD - 40];
let reason = "funny refname: ".to_owned() + &"y".repeat(200);
let echoed = echoable_name(&name, reason.len());
let mut line = b"ng ".to_vec();
line.extend_from_slice(&echoed);
line.push(b' ');
line.extend_from_slice(reason.as_bytes());
let mut out = Vec::new();
gunnar_pktline::write_text(&mut out, &line).unwrap_or_else(|err| {
panic!(
"the `ng` line for a refused {}-byte name does not fit in a pkt-line ({err}), \
so the whole report fails to write and the session dies on the refusal path",
name.len()
)
});
assert!(
echoed.ends_with("…[truncated]".as_bytes()),
"the name was cut to fit and the client is not told; a truncated name that \
does not say so is a name the client cannot match against its own list"
);
let short = b"refs/heads/main".to_vec();
assert_eq!(
echoable_name(&short, reason.len()),
short,
"a name that fits was truncated anyway"
);
}
}