use gix_hash::Kind;
pub const PRODUCT: &str = "gunnar";
pub fn default_agent() -> String {
format!("{PRODUCT}/{}", env!("CARGO_PKG_VERSION"))
}
pub fn object_format_name(kind: Kind) -> &'static str {
match kind {
Kind::Sha1 => "sha1",
Kind::Sha256 => "sha256",
_ => "unsupported",
}
}
pub fn object_format_from_name(name: &str) -> Option<Kind> {
match name {
"sha1" => Some(Kind::Sha1),
"sha256" => Some(Kind::Sha256),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Value {
None,
Required,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Argument {
pub name: &'static str,
pub value: Value,
pub feature: Option<&'static str>,
}
const fn plain(name: &'static str, value: Value) -> Argument {
Argument {
name,
value,
feature: None,
}
}
const fn gated(name: &'static str, value: Value, feature: &'static str) -> Argument {
Argument {
name,
value,
feature: Some(feature),
}
}
pub const LS_REFS_ARGUMENTS: &[Argument] = &[
plain("symrefs", Value::None),
plain("peel", Value::None),
plain("ref-prefix", Value::Required),
gated("unborn", Value::None, "unborn"),
];
pub const FETCH_ARGUMENTS: &[Argument] = &[
plain("want", Value::Required),
plain("have", Value::Required),
plain("done", Value::None),
plain("thin-pack", Value::None),
plain("no-progress", Value::None),
plain("include-tag", Value::None),
plain("ofs-delta", Value::None),
gated("shallow", Value::Required, "shallow"),
gated("deepen", Value::Required, "shallow"),
gated("deepen-relative", Value::None, "shallow"),
gated("deepen-since", Value::Required, "shallow"),
gated("deepen-not", Value::Required, "shallow"),
gated("filter", Value::Required, "filter"),
gated("want-ref", Value::Required, "ref-in-want"),
gated("wait-for-done", Value::None, "wait-for-done"),
];
pub fn argument(table: &'static [Argument], name: &str) -> Option<&'static Argument> {
table.iter().find(|a| a.name == name)
}
pub fn features(table: &'static [Argument]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for arg in table {
if let Some(feature) = arg.feature {
if !out.contains(&feature) {
out.push(feature);
}
}
}
out
}
pub fn capability(name: &str, table: &'static [Argument]) -> String {
let features = features(table);
if features.is_empty() {
name.to_owned()
} else {
format!("{name}={}", features.join(" "))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Offered {
pub filter: bool,
}
impl Default for Offered {
fn default() -> Self {
Offered { filter: true }
}
}
impl Offered {
pub fn has(&self, feature: &str) -> bool {
match feature {
"filter" => self.filter,
_ => true,
}
}
pub fn accepts(&self, argument: &Argument) -> bool {
argument.feature.is_none_or(|f| self.has(f))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Honoured {
By {
file: &'static str,
code: &'static str,
},
ByDependency {
krate: &'static str,
item: &'static str,
},
Vacuously(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapValue {
Bare,
ObjectFormat,
Agent,
Features(&'static [Argument]),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capability {
pub name: &'static str,
pub value: CapValue,
pub offered: Option<&'static str>,
pub honoured: Honoured,
pub requires: Option<crate::upload_pack::WantPolicy>,
}
const fn cap(name: &'static str, code: &'static str, file: &'static str) -> Capability {
Capability {
name,
value: CapValue::Bare,
offered: None,
honoured: Honoured::By { file, code },
requires: None,
}
}
const RECEIVE: &str = "crates/gunnar-wire/src/receive_pack.rs";
const UPLOAD: &str = "crates/gunnar-wire/src/upload_pack.rs";
const AGENT: Capability = Capability {
name: "agent",
value: CapValue::Agent,
offered: None,
honoured: Honoured::Vacuously(
"an identifier for the peer's logs. There is no behaviour attached to \
it at either end; git itself only records it.",
),
requires: None,
};
const OBJECT_FORMAT: Capability = Capability {
name: "object-format",
value: CapValue::ObjectFormat,
offered: None,
honoured: Honoured::By {
file: "crates/gunnar-wire/src/caps.rs",
code: "pub fn check_object_format",
},
requires: None,
};
pub const UPLOAD_PACK_V0: &[Capability] = &[
cap(
"multi_ack_detailed",
r#"peer.has("multi_ack_detailed")"#,
UPLOAD,
),
cap("no-done", r#"peer.has("no-done")"#, UPLOAD),
cap("side-band-64k", r#"peer.has("side-band-64k")"#, UPLOAD),
cap("thin-pack", r#"peer.has("thin-pack")"#, UPLOAD),
cap("ofs-delta", r#"peer.has("ofs-delta")"#, UPLOAD),
cap("no-progress", r#"peer.has("no-progress")"#, UPLOAD),
cap("include-tag", r#"peer.has("include-tag")"#, UPLOAD),
cap("shallow", r#""shallow" => request"#, UPLOAD),
cap("deepen-since", "request.shallow.since =", UPLOAD),
cap("deepen-not", r#""deepen-not" => request"#, UPLOAD),
cap(
"deepen-relative",
r#""deepen-relative" => request.shallow.relative = true"#,
UPLOAD,
),
Capability {
name: "filter",
value: CapValue::Bare,
offered: Some("filter"),
honoured: Honoured::By {
file: UPLOAD,
code: r#""filter" => out.filter"#,
},
requires: None,
},
Capability {
name: "allow-reachable-sha1-in-want",
value: CapValue::Bare,
offered: None,
honoured: Honoured::By {
file: UPLOAD,
code: "fn check_wants",
},
requires: Some(crate::upload_pack::WantPolicy::ReachableFromRefs),
},
Capability {
name: "allow-tip-sha1-in-want",
value: CapValue::Bare,
offered: None,
honoured: Honoured::By {
file: UPLOAD,
code: "fn check_wants",
},
requires: Some(crate::upload_pack::WantPolicy::Any),
},
OBJECT_FORMAT,
AGENT,
];
pub const RECEIVE_PACK: &[Capability] = &[
cap("report-status", r#"peer.has("report-status")"#, RECEIVE),
cap(
"report-status-v2",
r#"peer.has("report-status-v2")"#,
RECEIVE,
),
Capability {
name: "delete-refs",
value: CapValue::Bare,
offered: None,
honoured: Honoured::By {
file: RECEIVE,
code: "if command.is_delete()",
},
requires: None,
},
cap("side-band-64k", r#"peer.has("side-band-64k")"#, RECEIVE),
Capability {
name: "quiet",
value: CapValue::Bare,
offered: None,
honoured: Honoured::Vacuously(
"`quiet` asks receive-pack to suppress its progress reporting. \
gunnar's receive-pack emits no progress on that connection at \
all, so there is nothing to suppress and nothing to read the \
capability for. Vacuously honoured, and recorded as such rather \
than counted as a reader.",
),
requires: None,
},
cap("atomic", r#"peer.has("atomic")"#, RECEIVE),
Capability {
name: "ofs-delta",
value: CapValue::Bare,
offered: None,
honoured: Honoured::ByDependency {
krate: "gunnar-pack",
item: "index_thin_pack",
},
requires: None,
},
cap("push-options", r#"peer.has("push-options")"#, RECEIVE),
OBJECT_FORMAT,
AGENT,
];
pub const UPLOAD_PACK_V2: &[Capability] = &[
AGENT,
Capability {
name: "ls-refs",
value: CapValue::Features(LS_REFS_ARGUMENTS),
offered: None,
honoured: Honoured::By {
file: UPLOAD,
code: "impl LsRefsRequest",
},
requires: None,
},
Capability {
name: "fetch",
value: CapValue::Features(FETCH_ARGUMENTS),
offered: None,
honoured: Honoured::By {
file: UPLOAD,
code: "impl FetchRequest",
},
requires: None,
},
Capability {
name: "server-option",
value: CapValue::Bare,
offered: None,
honoured: Honoured::Vacuously(
"protocol v2 says a server SHOULD ignore server options it does \
not understand, and gunnar defines none. The lines are accepted \
into `PeerCapabilities` rather than rejected, so a client that \
sends one is not broken by it. This is NOT the `push-options` \
case: git hands push options to `pre-receive`, so discarding \
those was a wrong answer, while discarding server options is \
what the specification asks for.",
),
requires: None,
},
OBJECT_FORMAT,
];
pub const ADVERTISED: &[(&str, &[Capability])] = &[
("upload-pack v2", UPLOAD_PACK_V2),
("upload-pack v0", UPLOAD_PACK_V0),
("receive-pack", RECEIVE_PACK),
];
pub fn render(
cap: &Capability,
hash_kind: Kind,
agent: &str,
offered: Offered,
want_policy: crate::upload_pack::WantPolicy,
) -> Option<String> {
if let Some(feature) = cap.offered {
if !offered.has(feature) {
return None;
}
}
if let Some(required) = cap.requires {
if !want_policy.advertises(required) {
return None;
}
}
Some(match cap.value {
CapValue::Bare => cap.name.to_owned(),
CapValue::ObjectFormat => format!("{}={}", cap.name, object_format_name(hash_kind)),
CapValue::Agent => format!("{}={agent}", cap.name),
CapValue::Features(table) => {
let features: Vec<&str> = features(table)
.into_iter()
.filter(|f| offered.has(f))
.collect();
if features.is_empty() {
cap.name.to_owned()
} else {
format!("{}={}", cap.name, features.join(" "))
}
}
})
}
pub fn advertise(
table: &'static [Capability],
hash_kind: Kind,
agent: &str,
offered: Offered,
want_policy: crate::upload_pack::WantPolicy,
) -> Vec<String> {
table
.iter()
.filter_map(|cap| render(cap, hash_kind, agent, offered, want_policy))
.collect()
}
pub fn fetch_capability(offered: Offered) -> String {
let features: Vec<&str> = features(FETCH_ARGUMENTS)
.into_iter()
.filter(|f| offered.has(f))
.collect();
if features.is_empty() {
"fetch".to_owned()
} else {
format!("fetch={}", features.join(" "))
}
}
pub fn ls_refs_capability() -> String {
capability("ls-refs", LS_REFS_ARGUMENTS)
}
pub fn upload_pack_v0_capabilities(
hash_kind: Kind,
agent: &str,
offered: Offered,
want_policy: crate::upload_pack::WantPolicy,
) -> Vec<String> {
advertise(UPLOAD_PACK_V0, hash_kind, agent, offered, want_policy)
}
pub fn receive_pack_capabilities(hash_kind: Kind, agent: &str) -> Vec<String> {
advertise(
RECEIVE_PACK,
hash_kind,
agent,
Offered::default(),
crate::upload_pack::WantPolicy::default(),
)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PeerCapabilities {
entries: Vec<(String, Option<String>)>,
}
impl PeerCapabilities {
pub fn parse(text: &str) -> Self {
let entries = text
.split(' ')
.filter(|s| !s.is_empty())
.map(|item| match item.split_once('=') {
Some((k, v)) => (k.to_owned(), Some(v.to_owned())),
None => (item.to_owned(), None),
})
.collect();
PeerCapabilities { entries }
}
pub fn has(&self, name: &str) -> bool {
self.entries.iter().any(|(k, _)| k == name)
}
pub fn value(&self, name: &str) -> Option<&str> {
self.entries
.iter()
.find(|(k, _)| k == name)
.and_then(|(_, v)| v.as_deref())
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.entries.iter().map(|(k, _)| k.as_str())
}
}
pub fn check_object_format(ours: Kind, theirs: Option<&str>) -> crate::Result<()> {
let Some(theirs) = theirs else {
return Ok(());
};
if object_format_from_name(theirs) == Some(ours) {
Ok(())
} else {
Err(crate::Error::ObjectFormatMismatch {
ours: object_format_name(ours),
theirs: theirs.to_owned(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Declared {
As(&'static str),
Refuses {
seen_by: &'static [&'static str],
reason: &'static str,
},
RefusesUncovered(&'static str),
Invisible(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Policy {
pub field: &'static str,
pub declared: Declared,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PolicySurface {
pub surface: &'static str,
pub file: &'static str,
pub declaration: &'static str,
pub fields: &'static [Policy],
}
pub const SERVER_POLICIES: &[PolicySurface] = &[
PolicySurface {
surface: "upload-pack",
file: "crates/gunnar-wire/src/upload_pack.rs",
declaration: "pub struct Config {",
fields: &[
Policy {
field: "agent",
declared: Declared::As("agent"),
},
Policy {
field: "offered",
declared: Declared::As("filter"),
},
Policy {
field: "want_policy",
declared: Declared::As("allow-reachable-sha1-in-want"),
},
Policy {
field: "budget",
declared: Declared::Refuses {
seen_by: &["crates/gunnar-cli/tests/wire_config.rs::\
a_traversal_budget_of_one_refuses_a_clone_rather_than_truncating_it"],
reason: "the walk stops at the ceiling and the request is REFUSED, so the \
client's `git clone` fails with the server's message. A budget \
that truncated the pack instead would be a wrong answer and would \
belong in no arm of this enum — which is why the test asserts the \
clone failed AND that no repository was left behind, rather than \
asserting an exit code.",
},
},
Policy {
field: "limits",
declared: Declared::RefusesUncovered(
"UNCOVERED HERE, and the arm now says so rather than citing a path. All \
three ceilings raise a named error — `ObjectTooLarge`, `RequestTooLarge`, \
`WalkTooLarge` — that travels to the client as an `ERR` pkt-line or on \
band 3, and an object over the ceiling is REFUSED and not skipped, \
because a pack whose closure does not hold is a corrupt clone rather \
than a refused one; `limits.rs` argues that at length. The tests that \
watched two of the three arrive \
(`a_blob_over_the_ceiling_is_refused_by_name`, \
`a_v0_have_flood_is_refused_at_the_ceiling`, \
`a_v2_argument_flood_is_refused_at_the_ceiling`) are PARKED in edda's \
`storage-git-gix/parked-gix-2/` — quarantined salvage, in no published \
tarball and in no clone of this repository — because their fixture \
opened an FsStore and the packfile engine is amputated. THE WAY OUT is \
a MemStore fixture, which is gix-free and already in gunnar-store: port \
the three tests onto it, here, and this field goes back to `Refuses` \
with citations that resolve for everyone. The third ceiling, \
`walk_bytes`, IS covered — `walk_limit.rs` reads the sentence off the \
wire — and is a separate field for that reason.",
),
},
Policy {
field: "walk_pool",
declared: Declared::Refuses {
seen_by: &["crates/gunnar-wire/tests/walk_limit.rs::\
a_full_pool_is_a_different_sentence_from_a_request_that_will_never_fit"],
reason: "the server-wide memory ceiling, and the one policy here whose \
answer depends on what OTHER clients are doing. It refuses with \
`WalkPoolFull`, which is deliberately a different sentence from \
`WalkTooLarge`: this one says the machine is busy and a retry will \
work, and the other says the request will never fit. A client that \
cannot tell them apart retries the first forever and gives up on \
the second. Nothing is advertised, because nothing a client could \
be told before it asks would still be true when it did — the \
answer is a property of the moment.",
},
},
],
},
PolicySurface {
surface: "receive-pack",
file: "crates/gunnar-wire/src/receive_pack.rs",
declaration: "pub struct Config {",
fields: &[
Policy {
field: "agent",
declared: Declared::As("agent"),
},
Policy {
field: "budget",
declared: Declared::Refuses {
seen_by: &[
"crates/gunnar-wire/tests/protocol.rs::\
the_connectivity_budget_refuses_a_push_rather_than_accepting_it_unchecked",
"crates/gunnar-wire/tests/protocol.rs::\
a_mixed_push_refuses_only_the_reference_that_does_not_close",
],
reason: "the connectivity walk's ceiling. Exceeding it, like failing the \
walk itself, turns into a per-reference `RefStatus::Rejected` \
carrying the reason, which the status report puts in front of \
`git push`, and the reference does not move. The first test sets \
the budget to 1 over six unreachable commits and reads the status \
report; the second is what says the refusal is per reference and \
not per push. The first was written for this entry: until then \
the only citation available proved the reporting mechanism and \
never set a budget, which is a claim standing next to its \
evidence rather than on it.",
},
},
Policy {
field: "pack_ceiling",
declared: Declared::Refuses {
seen_by: &[
"crates/gunnar-wire/tests/pack_ceiling.rs::\
a_push_over_its_ceiling_is_cut_mid_transfer",
"crates/gunnar-wire/tests/pack_ceiling.rs::\
a_push_of_exactly_the_ceiling_lands",
"crates/gunnar-server/tests/pack_ceiling_store.rs::\
a_cut_push_stores_nothing_and_leaves_the_repository_usable",
"crates/gunnar-wire/tests/concurrent_pack_ceiling.rs::\
simultaneous_pushes_are_not_each_admitted_against_the_same_measurement",
],
reason: "the byte ceiling a push is held to WHILE it arrives, and the one \
policy here that a client cannot be told before it asks — because \
the thing it bounds is not knowable before it asks. A pack header \
carries an object count, not a byte count, so neither side knows \
the size of a push at negotiation time; there is no number to put \
in a capability and nothing a client could do differently if there \
were. So it refuses, and the refusal is the whole surface: the \
read is cut one byte past the ceiling, `unpack <reason>` carries \
the setter's own sentence (which names the ceiling and why \
— never the indexer's opinion of a truncated stream), \
and every reference is reported `ng`. The first citation is the \
one that matters and it asserts BYTES, not a verdict: a server \
that read the whole 400 GiB and then refused would satisfy any \
assertion about the report and is a different bug. The second is \
the boundary — a push of exactly the ceiling LANDS — and the \
third is the same cut against the real znippy engine, which is \
where `nothing was stored` costs something to be true. The \
fourth is the number's OTHER half, added 2026-09-08: the \
ceiling is one namespace's room and not one connection's, so \
eight pushes that measured it together spend it once between \
them (`PackCeiling::ledger`). Without it three green citations \
stood over a limit N simultaneous pushes could spend N times.",
},
},
Policy {
field: "notice",
declared: Declared::Invisible(
"It is not a policy: the push it rides on is ALLOWED. `notice` is one \
human-readable line put on the progress band (`remote: …`) beside a \
report that is otherwise byte-for-byte what it would have been — the \
metered appliance's `plan expired <date>; renew at <url>` for a tenant in \
Grace. Nothing about the request has to be shaped around knowing it, so \
`As` would be a capability no client would read; nothing is refused, so \
`Refuses` would have no arrival to cite. The three things that make \
`Invisible` the honest arm rather than the convenient one, all readable \
at `receive_pack.rs:1445-1455`: it is sent ONLY inside the `if sideband` \
arm, so a client that negotiated no sideband gets the bare report and \
never a line where the report should be; it is framed separately on band \
2, so a client that ignores band 2 parses the report untouched; and it is \
written BEFORE the report on band 1, which is presentation order for a \
person and nothing a parser depends on. Its absence is exactly the \
behaviour that shipped before it existed. If it ever gates, blocks or \
shortens a push, it stops being this arm.",
),
},
],
},
PolicySurface {
surface: "what is on offer",
file: "crates/gunnar-wire/src/caps.rs",
declaration: "pub struct Offered {",
fields: &[Policy {
field: "filter",
declared: Declared::As("filter"),
}],
},
];