use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::error::{Error, Result};
use crate::oid::{HashAlgo, ObjectId};
use super::pktline::{self, Packet};
const SIDEBAND_MAX_PAYLOAD: usize = pktline::MAX_PKTLINE_LEN - 4 - 1;
pub type LsRef = (String, ObjectId, Option<String>, Option<ObjectId>);
const AGENT: &str = concat!("puregit/", env!("CARGO_PKG_VERSION"));
pub fn capability_advertisement(algo: HashAlgo) -> Result<Vec<u8>> {
let mut out = Vec::new();
out.extend_from_slice(&pktline::encode_data(b"version 2\n")?);
out.extend_from_slice(&pktline::encode_data(
alloc::format!("agent={AGENT}\n").as_bytes(),
)?);
out.extend_from_slice(&pktline::encode_data(b"ls-refs=unborn\n")?);
out.extend_from_slice(&pktline::encode_data(
b"fetch=shallow filter ref-in-want packfile-uris\n",
)?);
out.extend_from_slice(&pktline::encode_data(
alloc::format!("object-format={}\n", algo.name()).as_bytes(),
)?);
out.extend_from_slice(&pktline::encode_control(&Packet::Flush));
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub name: String,
pub capabilities: Vec<String>,
pub args: Vec<String>,
}
impl Command {
pub fn parse(packets: &[Packet]) -> Result<Self> {
let mut name = None;
let mut capabilities = Vec::new();
let mut args = Vec::new();
let mut after_delim = false;
for packet in packets {
match packet {
Packet::Delim => after_delim = true,
Packet::Flush => break,
Packet::Data(d) => {
let line = trim_newline(d);
let text = String::from_utf8_lossy(line).to_string();
if after_delim {
args.push(text);
} else if let Some(cmd) = text.strip_prefix("command=") {
name = Some(cmd.to_string());
} else {
capabilities.push(text);
}
}
Packet::ResponseEnd => break,
}
}
Ok(Command {
name: name.ok_or_else(|| Error::Protocol("v2: request has no command".into()))?,
capabilities,
args,
})
}
pub fn has_arg(&self, arg: &str) -> bool {
self.args.iter().any(|a| a == arg)
}
}
pub fn encode_ls_refs_response(command: &Command, refs: &[LsRef]) -> Result<Vec<u8>> {
let want_symrefs = command.has_arg("symrefs");
let want_peel = command.has_arg("peel");
let prefixes: Vec<&str> = command
.args
.iter()
.filter_map(|a| a.strip_prefix("ref-prefix "))
.collect();
let mut out = Vec::new();
for (name, id, symref, peeled) in refs {
if !prefixes.is_empty() && !prefixes.iter().any(|p| name.starts_with(p)) {
continue;
}
let mut line = alloc::format!("{} {name}", id.to_hex());
if want_symrefs && let Some(target) = symref {
line.push_str(&alloc::format!(" symref-target:{target}"));
}
if want_peel && let Some(p) = peeled {
line.push_str(&alloc::format!(" peeled:{}", p.to_hex()));
}
line.push('\n');
out.extend_from_slice(&pktline::encode_data(line.as_bytes())?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Flush));
Ok(out)
}
#[derive(Debug, Clone, Default)]
pub struct FetchArgs {
pub wants: Vec<ObjectId>,
pub haves: Vec<ObjectId>,
pub done: bool,
pub thin_pack: bool,
pub no_progress: bool,
pub deepen: Option<u32>,
pub shallows: Vec<ObjectId>,
pub filter: Option<String>,
pub want_refs: Vec<String>,
}
impl FetchArgs {
pub fn parse(algo: HashAlgo, command: &Command) -> Result<Self> {
let mut args = FetchArgs::default();
for line in &command.args {
if let Some(oid) = line.strip_prefix("want ") {
args.wants.push(ObjectId::from_hex(algo, oid.trim())?);
} else if let Some(oid) = line.strip_prefix("have ") {
args.haves.push(ObjectId::from_hex(algo, oid.trim())?);
} else if let Some(oid) = line.strip_prefix("shallow ") {
args.shallows.push(ObjectId::from_hex(algo, oid.trim())?);
} else if let Some(depth) = line.strip_prefix("deepen ") {
args.deepen = Some(
depth
.trim()
.parse()
.map_err(|_| Error::Protocol("v2 deepen: invalid depth".into()))?,
);
} else if let Some(spec) = line.strip_prefix("filter ") {
args.filter = Some(spec.trim().to_string());
} else if let Some(name) = line.strip_prefix("want-ref ") {
args.want_refs.push(name.trim().to_string());
} else if line == "done" {
args.done = true;
} else if line == "thin-pack" {
args.thin_pack = true;
} else if line == "no-progress" {
args.no_progress = true;
}
}
Ok(args)
}
}
pub fn encode_fetch_response(pack: &[u8]) -> Result<Vec<u8>> {
encode_fetch_response_with_shallow(&[], pack)
}
pub fn encode_fetch_response_with_shallow(shallow: &[ObjectId], pack: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
if !shallow.is_empty() {
out.extend_from_slice(&pktline::encode_data(b"shallow-info\n")?);
for oid in shallow {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("shallow {}\n", oid.to_hex()).as_bytes(),
)?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Delim));
}
out.extend_from_slice(&pktline::encode_data(b"packfile\n")?);
for chunk in pack.chunks(SIDEBAND_MAX_PAYLOAD) {
let mut line = Vec::with_capacity(chunk.len() + 1);
line.push(1); line.extend_from_slice(chunk);
out.extend_from_slice(&pktline::encode_data(&line)?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Flush));
Ok(out)
}
pub fn parse_capability_advertisement(packets: &[Packet]) -> Result<Vec<String>> {
let mut caps = Vec::new();
let mut saw_version = false;
for packet in packets {
match packet {
Packet::Data(d) => {
let line = String::from_utf8_lossy(trim_newline(d)).to_string();
if !saw_version {
if line != "version 2" {
return Err(Error::Protocol("v2: advertisement is not version 2".into()));
}
saw_version = true;
} else {
caps.push(line);
}
}
Packet::Flush => break,
_ => {}
}
}
if !saw_version {
return Err(Error::Protocol("v2: empty advertisement".into()));
}
Ok(caps)
}
pub fn encode_command(name: &str, capabilities: &[String], args: &[String]) -> Result<Vec<u8>> {
let mut out = Vec::new();
out.extend_from_slice(&pktline::encode_data(
alloc::format!("command={name}\n").as_bytes(),
)?);
for cap in capabilities {
out.extend_from_slice(&pktline::encode_data(alloc::format!("{cap}\n").as_bytes())?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Delim));
for arg in args {
out.extend_from_slice(&pktline::encode_data(alloc::format!("{arg}\n").as_bytes())?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Flush));
Ok(out)
}
pub fn parse_ls_refs_response(
algo: HashAlgo,
packets: &[Packet],
) -> Result<Vec<super::AdvertisedRef>> {
let mut refs = Vec::new();
for packet in packets {
let line = match packet {
Packet::Data(d) => trim_newline(d),
Packet::Flush => break,
_ => continue,
};
let text = core::str::from_utf8(line)
.map_err(|_| Error::Protocol("v2 ls-refs: non-utf8 line".into()))?;
let mut parts = text.split(' ');
let (Some(oid), Some(name)) = (parts.next(), parts.next()) else {
continue;
};
refs.push(super::AdvertisedRef {
name: name.to_string(),
id: ObjectId::from_hex(algo, oid)?,
});
}
Ok(refs)
}
pub fn parse_fetch_response(packets: &[Packet]) -> Result<Vec<u8>> {
Ok(parse_fetch_response_full(HashAlgo::Sha1, packets)?.1)
}
pub fn parse_fetch_response_full(
algo: HashAlgo,
packets: &[Packet],
) -> Result<(Vec<ObjectId>, Vec<u8>)> {
let (shallow, _uris, pack) = parse_fetch_response_uris(algo, packets)?;
Ok((shallow, pack))
}
#[allow(clippy::type_complexity)]
pub fn parse_fetch_response_uris(
algo: HashAlgo,
packets: &[Packet],
) -> Result<(Vec<ObjectId>, Vec<(String, String)>, Vec<u8>)> {
let mut in_packfile = false;
let mut in_uris = false;
let mut shallow = Vec::new();
let mut uris: Vec<(String, String)> = Vec::new();
let mut pack = Vec::new();
for packet in packets {
let data = match packet {
Packet::Delim => {
in_uris = false;
continue;
}
Packet::Data(d) => d,
_ => continue,
};
if !in_packfile {
let line = trim_newline(data);
if line == b"packfile" {
in_packfile = true;
} else if line == b"packfile-uris" {
in_uris = true;
} else if in_uris {
let text = core::str::from_utf8(line)
.map_err(|_| Error::Protocol("v2 packfile-uris: non-utf8".into()))?;
if let Some((hash, uri)) = text.split_once(' ') {
uris.push((hash.trim().to_string(), uri.trim().to_string()));
}
} else if let Some(oid) = line.strip_prefix(b"shallow ") {
let text = core::str::from_utf8(oid)
.map_err(|_| Error::Protocol("v2 shallow: non-utf8".into()))?;
shallow.push(ObjectId::from_hex(algo, text.trim())?);
}
continue;
}
match data.first() {
Some(1) => pack.extend_from_slice(&data[1..]),
Some(2) => {} Some(3) => {
return Err(Error::Protocol(alloc::format!(
"v2 fetch error: {}",
String::from_utf8_lossy(&data[1..])
)));
}
_ => {}
}
}
if !in_packfile {
return Err(Error::Protocol(
"v2 fetch: response had no packfile section".into(),
));
}
Ok((shallow, uris, pack))
}
pub fn encode_fetch_sections(
acknowledgments: Option<&[ObjectId]>,
shallow: &[ObjectId],
unshallow: &[ObjectId],
wanted_refs: &[(ObjectId, String)],
packfile_uris: &[(String, String)],
pack: &[u8],
) -> Result<Vec<u8>> {
let mut out = Vec::new();
if let Some(common) = acknowledgments {
out.extend_from_slice(&pktline::encode_data(b"acknowledgments\n")?);
if common.is_empty() {
out.extend_from_slice(&pktline::encode_data(b"NAK\n")?);
} else {
for oid in common {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("ACK {}\n", oid.to_hex()).as_bytes(),
)?);
}
}
out.extend_from_slice(&pktline::encode_data(b"ready\n")?);
out.extend_from_slice(&pktline::encode_control(&Packet::Delim));
}
if !shallow.is_empty() || !unshallow.is_empty() {
out.extend_from_slice(&pktline::encode_data(b"shallow-info\n")?);
for oid in shallow {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("shallow {}\n", oid.to_hex()).as_bytes(),
)?);
}
for oid in unshallow {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("unshallow {}\n", oid.to_hex()).as_bytes(),
)?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Delim));
}
if !wanted_refs.is_empty() {
out.extend_from_slice(&pktline::encode_data(b"wanted-refs\n")?);
for (oid, name) in wanted_refs {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("{} {name}\n", oid.to_hex()).as_bytes(),
)?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Delim));
}
if !packfile_uris.is_empty() {
out.extend_from_slice(&pktline::encode_data(b"packfile-uris\n")?);
for (hash, uri) in packfile_uris {
out.extend_from_slice(&pktline::encode_data(
alloc::format!("{hash} {uri}\n").as_bytes(),
)?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Delim));
}
out.extend_from_slice(&pktline::encode_data(b"packfile\n")?);
for chunk in pack.chunks(SIDEBAND_MAX_PAYLOAD) {
let mut line = Vec::with_capacity(chunk.len() + 1);
line.push(1);
line.extend_from_slice(chunk);
out.extend_from_slice(&pktline::encode_data(&line)?);
}
out.extend_from_slice(&pktline::encode_control(&Packet::Flush));
Ok(out)
}
fn trim_newline(line: &[u8]) -> &[u8] {
match line.strip_suffix(b"\n") {
Some(rest) => rest,
None => line,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capability_advertisement_announces_v2_and_commands() {
let bytes = capability_advertisement(HashAlgo::Sha1).unwrap();
let packets = pktline::decode_all(&bytes).unwrap();
let lines: Vec<String> = packets
.iter()
.filter_map(|p| match p {
Packet::Data(d) => Some(String::from_utf8_lossy(trim_newline(d)).to_string()),
_ => None,
})
.collect();
assert_eq!(lines[0], "version 2");
assert!(lines.iter().any(|l| l == "ls-refs=unborn"));
assert!(
lines
.iter()
.any(|l| l == "fetch=shallow filter ref-in-want packfile-uris")
);
assert!(lines.iter().any(|l| l == "object-format=sha1"));
}
#[test]
fn parses_a_command_with_caps_and_args() {
let mut req = Vec::new();
req.extend(pktline::encode_data(b"command=fetch\n").unwrap());
req.extend(pktline::encode_data(b"object-format=sha1\n").unwrap());
req.extend(pktline::encode_control(&Packet::Delim));
req.extend(
pktline::encode_data(b"want 1111111111111111111111111111111111111111\n").unwrap(),
);
req.extend(pktline::encode_data(b"done\n").unwrap());
req.extend(pktline::encode_control(&Packet::Flush));
let cmd = Command::parse(&pktline::decode_all(&req).unwrap()).unwrap();
assert_eq!(cmd.name, "fetch");
assert!(cmd.capabilities.iter().any(|c| c == "object-format=sha1"));
assert!(cmd.has_arg("done"));
let args = FetchArgs::parse(HashAlgo::Sha1, &cmd).unwrap();
assert_eq!(args.wants.len(), 1);
assert!(args.done);
}
#[test]
fn ls_refs_filters_by_prefix_and_adds_attrs() {
let mut req = Vec::new();
req.extend(pktline::encode_data(b"command=ls-refs\n").unwrap());
req.extend(pktline::encode_control(&Packet::Delim));
req.extend(pktline::encode_data(b"symrefs\n").unwrap());
req.extend(pktline::encode_data(b"ref-prefix refs/heads/\n").unwrap());
req.extend(pktline::encode_control(&Packet::Flush));
let cmd = Command::parse(&pktline::decode_all(&req).unwrap()).unwrap();
let id = ObjectId::from_hex(HashAlgo::Sha1, &"a".repeat(40)).unwrap();
let refs = alloc::vec![
(
"HEAD".to_string(),
id,
Some("refs/heads/main".to_string()),
None
),
("refs/heads/main".to_string(), id, None, None),
("refs/tags/v1".to_string(), id, None, None),
];
let resp = encode_ls_refs_response(&cmd, &refs).unwrap();
let text = String::from_utf8_lossy(&resp);
assert!(text.contains("refs/heads/main"));
assert!(!text.contains("refs/tags/v1"));
assert!(!text.contains("HEAD"));
}
#[test]
fn client_parses_server_capability_advertisement() {
let bytes = capability_advertisement(HashAlgo::Sha1).unwrap();
let caps = parse_capability_advertisement(&pktline::decode_all(&bytes).unwrap()).unwrap();
assert!(caps.iter().any(|c| c == "ls-refs=unborn"));
assert!(
caps.iter()
.any(|c| c == "fetch=shallow filter ref-in-want packfile-uris")
);
let v1 = pktline::encode_data(b"deadbeef refs/heads/main\n").unwrap();
assert!(parse_capability_advertisement(&pktline::decode_all(&v1).unwrap()).is_err());
}
#[test]
fn ls_refs_encode_then_parse_round_trips() {
let caps = alloc::vec!["object-format=sha1".to_string()];
let args = alloc::vec!["symrefs".to_string(), "ref-prefix refs/".to_string()];
let request = encode_command("ls-refs", &caps, &args).unwrap();
let cmd = Command::parse(&pktline::decode_all(&request).unwrap()).unwrap();
assert_eq!(cmd.name, "ls-refs");
let id = ObjectId::from_hex(HashAlgo::Sha1, &"b".repeat(40)).unwrap();
let refs = alloc::vec![
("refs/heads/main".to_string(), id, None, None),
("refs/tags/v1".to_string(), id, None, None),
];
let response = encode_ls_refs_response(&cmd, &refs).unwrap();
let parsed =
parse_ls_refs_response(HashAlgo::Sha1, &pktline::decode_all(&response).unwrap())
.unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].name, "refs/heads/main");
assert_eq!(parsed[0].id, id);
}
#[test]
fn client_demuxes_the_fetch_packfile_section() {
let response = encode_fetch_response(b"PACKcontents").unwrap();
let pack = parse_fetch_response(&pktline::decode_all(&response).unwrap()).unwrap();
assert_eq!(pack, b"PACKcontents");
let empty = pktline::encode_control(&Packet::Flush);
assert!(parse_fetch_response(&pktline::decode_all(&empty).unwrap()).is_err());
}
#[test]
fn packfile_uris_section_round_trips() {
let uris = alloc::vec![
(
"aaaa".to_string(),
"https://cdn.example/pack-aaaa.pack".to_string()
),
(
"bbbb".to_string(),
"https://cdn.example/pack-bbbb.pack".to_string()
),
];
let resp = encode_fetch_sections(None, &[], &[], &[], &uris, b"PACKmain").unwrap();
let (_shallow, parsed_uris, pack) =
parse_fetch_response_uris(HashAlgo::Sha1, &pktline::decode_all(&resp).unwrap())
.unwrap();
assert_eq!(pack, b"PACKmain");
assert_eq!(parsed_uris, uris);
let plain = encode_fetch_sections(None, &[], &[], &[], &[], b"PACKx").unwrap();
let (_s, no_uris, _p) =
parse_fetch_response_uris(HashAlgo::Sha1, &pktline::decode_all(&plain).unwrap())
.unwrap();
assert!(no_uris.is_empty());
}
#[test]
fn fetch_response_wraps_pack_in_sideband() {
let resp = encode_fetch_response(b"PACKDATA").unwrap();
let packets = pktline::decode_all(&resp).unwrap();
assert_eq!(packets[0], Packet::Data(b"packfile\n".to_vec()));
match &packets[1] {
Packet::Data(d) => {
assert_eq!(d[0], 1);
assert_eq!(&d[1..], b"PACKDATA");
}
other => panic!("expected sideband data, got {other:?}"),
}
assert_eq!(packets.last(), Some(&Packet::Flush));
}
}