g1t/services/repos/src/git_http.rs
//! Git over HTTPS: the smart HTTP remote at `/<namespace>/<repo>.git`,
//! proxied to the git store with a short-lived token.
use g1t_contracts::identity::GitCredentialsArgs;
use g1t_contracts::repos::{GitAccess, GitService, RepoPath};
use g1t_contracts::{FailureCode, Outcome, Viewer};
use worker::js_sys::Uint8Array;
use worker::{Fetch, Fetcher, Headers, Method, Request, RequestInit, Response, Result, Url};
const ENDPOINTS: [&str; 3] = ["info/refs", "git-upload-pack", "git-receive-pack"];
const FORWARDED_HEADERS: [&str; 5] = [
"accept",
"content-encoding",
"content-type",
"git-protocol",
"user-agent",
];
/// A git request, parsed from its URL.
pub struct GitRequest {
pub path: RepoPath,
pub endpoint: &'static str,
pub service: GitService,
}
/// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the
/// request is not git's.
pub fn parse(url: &Url) -> Option<GitRequest> {
let path = url.path().strip_prefix('/')?;
let endpoint = ENDPOINTS
.into_iter()
.find(|endpoint| path.ends_with(&format!("/{endpoint}")))?;
let repo = &path[..path.len() - endpoint.len() - 1];
let (namespace, name) = repo.split_once('/')?;
let name = name.strip_suffix(".git").unwrap_or(name);
if namespace.is_empty() || name.is_empty() || name.contains('/') {
return None;
}
let service = if endpoint == "info/refs" {
url.query_pairs()
.find(|(key, _)| key == "service")
.map(|(_, value)| value.into_owned())?
} else {
endpoint.to_owned()
};
let service = match service.as_str() {
"git-upload-pack" => GitService::UploadPack,
"git-receive-pack" => GitService::ReceivePack,
_ => return None,
};
Some(GitRequest {
path: RepoPath {
namespace: namespace.to_owned(),
name: name.to_owned(),
},
endpoint,
service,
})
}
/// The user named by an HTTP Basic `Authorization` header, as git sends it.
pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> {
let Some(header) = request.headers().get("authorization")? else {
return Ok(None);
};
let Some((scheme, encoded)) = header.split_once(' ') else {
return Ok(None);
};
if !scheme.eq_ignore_ascii_case("basic") {
return Ok(None);
}
let Some(decoded) = decode_base64(encoded.trim()) else {
return Ok(None);
};
let Some((username, secret)) = decoded.split_once(':') else {
return Ok(None);
};
g1t_kit::call(
identity,
"user_for_git_credentials",
&GitCredentialsArgs {
username: username.to_owned(),
secret: secret.to_owned(),
},
)
.await
}
/// Standard base64 to a UTF-8 string, or `None` if either step fails.
fn decode_base64(input: &str) -> Option<String> {
let mut bytes = Vec::with_capacity(input.len() * 3 / 4);
let mut buffer = 0u32;
let mut bits = 0;
for byte in input.bytes().filter(|byte| *byte != b'=') {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => return None,
};
buffer = (buffer << 6) | u32::from(value);
bits += 6;
if bits >= 8 {
bits -= 8;
bytes.push((buffer >> bits) as u8);
}
}
String::from_utf8(bytes).ok()
}
/// The response for a refused git request. Anonymous callers are asked to
/// authenticate, which is what makes git prompt for credentials.
pub fn refuse<T>(outcome: Outcome<T>) -> Result<Response> {
let Outcome::Fail(failure) = outcome else {
return Response::error("Not found", 404);
};
let mut response = Response::error(failure.message, failure.code.http_status())?;
if failure.code == FailureCode::Unauthenticated {
response
.headers_mut()
.set("www-authenticate", "Basic realm=\"g1t\"")?;
}
Ok(response)
}
/// `url` with its first path segment, the workspace, replaced by `slug`.
pub fn with_namespace(url: &Url, slug: &str) -> Option<String> {
let rest = url.path().strip_prefix('/')?.split_once('/')?.1;
let mut moved = url.clone();
moved.set_path(&format!("/{slug}/{rest}"));
Some(moved.to_string())
}
/// Where a git request for a renamed workspace's old address should go
/// now, if its first segment is an old slug that still redirects.
pub async fn renamed(url: &Url, identity: &Fetcher) -> Result<Option<String>> {
let Some(old) = url.path().strip_prefix('/').and_then(|path| path.split('/').next()) else {
return Ok(None);
};
let current: Option<String> = g1t_kit::call(
identity,
"resolve_slug",
&g1t_contracts::identity::SlugArgs {
slug: old.to_owned(),
},
)
.await?;
Ok(current.and_then(|slug| with_namespace(url, &slug)))
}
/// `url` with its repository, the first two path segments, replaced by
/// `to`: where a request for a transferred repository's old path goes.
/// Keeps whether the old address ended in `.git`.
pub fn transferred(url: &Url, to: &RepoPath) -> Option<String> {
let path = url.path().strip_prefix('/')?;
let mut segments = path.splitn(3, '/');
let (_, name, rest) = (segments.next()?, segments.next()?, segments.next()?);
let suffix = if name.ends_with(".git") { ".git" } else { "" };
let mut moved = url.clone();
moved.set_path(&format!("/{}/{}{suffix}/{rest}", to.namespace, to.name));
Some(moved.to_string())
}
/// A permanent redirect: 301 for git's first request for refs, which it
/// follows and then uses the new address for the rest; 308 for the
/// others, so a POST stays a POST.
pub fn moved(location: &str, get: bool) -> Result<Response> {
let mut response = Response::empty()?.with_status(if get { 301 } else { 308 });
response.headers_mut().set("location", location)?;
Ok(response)
}
const ZERO_ID: &str = "0000000000000000000000000000000000000000";
const HEADS: &str = "refs/heads/";
const TAGS: &str = "refs/tags/";
/// One ref a push asks to change.
struct Command {
old: String,
new: String,
name: String,
}
/// The commands at the start of a receive-pack request, and the
/// capabilities the client sent with the first of them.
fn commands(body: &[u8]) -> (Vec<Command>, String) {
let mut commands = Vec::new();
let mut capabilities = String::new();
let mut position = 0;
// Commands are pkt-lines; a flush packet ends them and the pack follows.
while let Some(length) = body
.get(position..position + 4)
.and_then(|hex| std::str::from_utf8(hex).ok())
.and_then(|hex| usize::from_str_radix(hex, 16).ok())
{
if length < 4 || position + length > body.len() {
break;
}
let line = &body[position + 4..position + length];
position += length;
// `<old> <new> <ref>`, and on the first command a NUL then capabilities.
let mut halves = line.splitn(2, |byte| *byte == 0);
let command = halves.next().unwrap_or_default();
if let Some(rest) = halves.next() {
capabilities = String::from_utf8_lossy(rest).trim().to_owned();
}
let Ok(command) = std::str::from_utf8(command) else {
continue;
};
let mut parts = command.trim_end().splitn(3, ' ');
if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) {
commands.push(Command {
old: old.to_owned(),
new: new.to_owned(),
name: name.to_owned(),
});
}
}
(commands, capabilities)
}
/// The bytes of the pack a receive-pack request carries: everything after
/// the flush packet that ends its commands. Zero for a push that only
/// deletes refs.
pub(crate) fn pack_bytes(body: &[u8]) -> u64 {
let mut position = 0;
while let Some(length) = body
.get(position..position + 4)
.and_then(|hex| std::str::from_utf8(hex).ok())
.and_then(|hex| usize::from_str_radix(hex, 16).ok())
{
if length == 0 {
return (body.len() - position - 4) as u64;
}
if length < 4 || position + length > body.len() {
break;
}
position += length;
}
0
}
fn pkt_line(payload: &[u8]) -> Vec<u8> {
let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
line.extend_from_slice(payload);
line
}
/// What git is told when a push would change a protected branch: every ref
/// in it is declined, with the reason against the protected one, so that
/// git prints it beside the branch. `None` if the push leaves the branch
/// alone, or creates it in a repository that does not have it yet.
fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
let (commands, capabilities) = commands(body);
let reference = format!("{HEADS}{protected}");
if !commands
.iter()
.any(|command| command.name == reference && command.old != ZERO_ID)
{
return None;
}
let mut report = pkt_line(b"unpack ok\n");
for command in &commands {
let reason = if command.name == reference {
format!("{protected} is protected: push a branch and open a pull request")
} else {
format!("not pushed, because the same push would change {protected}")
};
report.extend(pkt_line(
format!("ng {} {reason}\n", command.name).as_bytes(),
));
}
report.extend_from_slice(b"0000");
Some(framed(report, &capabilities, &[]))
}
/// A report-status as git expects it: inside channel 1 when the client
/// asked for side-band, after `messages` on channel 2, which git prints as
/// `remote:` lines. Without side-band the messages cannot be shown.
fn framed(report: Vec<u8>, capabilities: &str, messages: &[String]) -> Vec<u8> {
let sideband = capabilities
.split(' ')
.any(|capability| capability.starts_with("side-band"));
if !sideband {
return report;
}
let mut body = Vec::new();
for message in messages {
let mut packet = vec![2u8];
packet.extend_from_slice(message.as_bytes());
packet.push(b'\n');
body.extend(pkt_line(&packet));
}
// side-band (not -64k) packets carry at most 1000 bytes.
for chunk in report.chunks(990) {
let mut packet = vec![1u8];
packet.extend_from_slice(chunk);
body.extend(pkt_line(&packet));
}
body.extend_from_slice(b"0000");
body
}
/// Declines every ref in a push with `reason`, explaining why in
/// `messages`: what push protection answers when a push adds a secret.
pub fn declined(body: &[u8], reason: &str, messages: &[String]) -> Result<Response> {
let (commands, capabilities) = commands(body);
let mut report = pkt_line(b"unpack ok\n");
for command in &commands {
report.extend(pkt_line(format!("ng {} {reason}\n", command.name).as_bytes()));
}
report.extend_from_slice(b"0000");
let headers = Headers::new();
headers.set("content-type", "application/x-git-receive-pack-result")?;
headers.set("cache-control", "no-cache")?;
Ok(Response::from_bytes(framed(report, &capabilities, messages))?.with_headers(headers))
}
/// A branch or tag a push asks to move.
#[derive(Debug, PartialEq, Eq)]
pub struct Pushed {
/// The full ref: `refs/heads/main`, `refs/tags/v1`.
pub git_ref: String,
/// Where it pointed before; `None` for a new ref.
pub before: Option<String>,
pub after: String,
}
impl Pushed {
pub fn branch(&self) -> Option<&str> {
self.git_ref.strip_prefix(HEADS)
}
}
/// The branches and tags a push asks to move, read from the commands at the
/// start of a receive-pack request. Deletions and other refs are left out.
fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
commands(body)
.0
.into_iter()
.filter(|command| command.new != ZERO_ID)
.filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
.map(|Command { old, new, name }| Pushed {
git_ref: name,
before: (old != ZERO_ID).then_some(old),
after: new,
})
.collect()
}
/// The git store's answer, and what the request asked it to change.
pub struct Forwarded {
pub response: Response,
/// For a push: the branches and tags it asks to move, and the commits
/// to move them to. Whether each moved is for the caller to confirm.
pub pushed: Vec<Pushed>,
/// For a push: the size of the pack it sent, for the storage meter.
pub pack_bytes: u64,
}
/// What became of a git request.
pub enum Push {
Forwarded(Forwarded),
/// A push to a protected branch, answered here without reaching the store.
Refused(Response),
/// A push that adds a secret nobody allowed, answered the same way.
Blocked(Response),
}
/// One packet of a pkt-line stream: data, or a flush (`0000`), delimiter
/// (`0001`) or response-end (`0002`) packet, kept as its four bytes.
#[derive(Debug, PartialEq, Eq)]
enum Packet {
Data(Vec<u8>),
Special([u8; 4]),
}
/// The packets in `bytes`, or `None` if it is not a whole pkt-line stream.
fn packets(bytes: &[u8]) -> Option<Vec<Packet>> {
let mut out = Vec::new();
let mut position = 0;
while position < bytes.len() {
let header = bytes.get(position..position + 4)?;
let length = usize::from_str_radix(std::str::from_utf8(header).ok()?, 16).ok()?;
if length < 4 {
out.push(Packet::Special(header.try_into().ok()?));
position += 4;
continue;
}
out.push(Packet::Data(bytes.get(position + 4..position + length)?.to_vec()));
position += length;
}
Some(out)
}
fn encode(packets: &[Packet]) -> Vec<u8> {
let mut out = Vec::new();
for packet in packets {
match packet {
Packet::Data(data) => out.extend(pkt_line(data)),
Packet::Special(bytes) => out.extend_from_slice(bytes),
}
}
out
}
/// A ref advertisement (`info/refs` for upload-pack) or a protocol v2
/// `ls-refs` answer with `HEAD` pointing at `branch`, the repository's
/// default branch as g1t keeps it, so a clone checks it out. The git store
/// holds the HEAD it was created with; g1t can change the default branch
/// since. `None` when there is nothing to change: no `HEAD` line, `HEAD`
/// already names `branch`, or `branch` is not advertised.
pub fn with_head(body: &[u8], branch: &str) -> Option<Vec<u8>> {
let mut packets = packets(body)?;
let target = format!("{HEADS}{branch}");
let oid = packets.iter().find_map(|packet| {
let Packet::Data(data) = packet else { return None };
let line = data.split(|byte| *byte == 0).next()?;
let line = std::str::from_utf8(line).ok()?.trim_end();
let (oid, name) = line.split_once(' ')?;
// v2 lines may carry attributes after the name.
let name = name.split(' ').next()?;
(name == target).then(|| oid.to_owned())
})?;
let mut changed = false;
for packet in &mut packets {
let Packet::Data(data) = packet else { continue };
let text = String::from_utf8_lossy(data).into_owned();
let Some((_, rest)) = text.split_once(' ') else { continue };
if !(rest.starts_with("HEAD\0") || rest.starts_with("HEAD\n") || rest.starts_with("HEAD ") || rest == "HEAD") {
continue;
}
let mut line = format!("{oid} {rest}");
// v0: `symref=HEAD:refs/heads/<old>` among the capabilities.
// v2: `symref-target:refs/heads/<old>` after the name.
for marker in ["symref=HEAD:", "symref-target:"] {
if let Some(at) = line.find(marker) {
let start = at + marker.len();
let end = line[start..]
.find([' ', '\n', '\0'])
.map_or(line.len(), |offset| start + offset);
line.replace_range(start..end, &target);
}
}
changed = line != text;
if changed {
*data = line.into_bytes();
}
break;
}
changed.then(|| encode(&packets))
}
/// Whether a request to the git store is one whose answer names `HEAD`:
/// the ref advertisement for a fetch, or a protocol v2 `ls-refs`.
fn names_head(git: &GitRequest, body: Option<&[u8]>) -> bool {
if git.service != GitService::UploadPack {
return false;
}
match body {
None => git.endpoint == "info/refs",
Some(body) => {
git.endpoint == "git-upload-pack"
&& body.windows(b"command=ls-refs".len()).any(|window| window == b"command=ls-refs")
}
}
}
/// Sends the request on to the git store and returns its response as is,
/// unless it is a push that would change the `protected` branch, or one
/// that `scan` (push protection) answers itself. A fetch's ref listing has
/// its `HEAD` pointed at `default_branch` (see [`with_head`]).
pub async fn forward(
mut request: Request,
git: &GitRequest,
access: &GitAccess,
protected: Option<&str>,
default_branch: Option<&str>,
scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
) -> Result<Push> {
let headers = Headers::new();
headers.set("authorization", &format!("Bearer {}", access.token))?;
for name in FORWARDED_HEADERS {
if let Some(value) = request.headers().get(name)? {
headers.set(name, &value)?;
}
}
let query = request
.url()?
.query()
.map(|query| format!("?{query}"))
.unwrap_or_default();
let mut init = RequestInit::new();
init.with_method(request.method()).with_headers(headers);
let mut pushed = Vec::new();
let mut pack = 0;
let mut lists_head = request.method() == Method::Get && names_head(git, None);
if request.method() == Method::Post {
// Pushes are capped at 100 MB by the platform, so buffering is safe.
let body = request.bytes().await?;
if git.endpoint == "git-receive-pack" {
if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) {
let headers = Headers::new();
headers.set("content-type", "application/x-git-receive-pack-result")?;
headers.set("cache-control", "no-cache")?;
return Ok(Push::Refused(
Response::from_bytes(report)?.with_headers(headers),
));
}
if let Some(response) = scan(&body).await? {
return Ok(Push::Blocked(response));
}
pushed = pushed_branches(&body);
pack = pack_bytes(&body);
}
lists_head = names_head(git, Some(&body));
init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
}
let upstream =
Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
let mut response = Fetch::Request(upstream).send().await?;
if let (true, Some(branch)) = (lists_head, default_branch)
&& response.status_code() == 200
{
let headers = response.headers().clone();
headers.delete("content-length")?;
let body = response.bytes().await?;
let body = with_head(&body, branch).unwrap_or(body);
response = Response::from_bytes(body)?.with_headers(headers);
}
Ok(Push::Forwarded(Forwarded {
response,
pushed,
pack_bytes: pack,
}))
}
#[cfg(test)]
mod tests {
use super::{Pushed, RepoPath, Url, ZERO_ID, framed, pack_bytes, pushed_branches, refusal, transferred, with_head, with_namespace};
#[test]
fn a_renamed_repository_redirects_to_its_new_name() {
// A rename keeps the old path in the same table as a transfer, so
// the old remote is sent to the new name the same way.
let to = RepoPath {
namespace: "acme".into(),
name: "booster".into(),
};
let url = Url::parse("https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack").unwrap();
assert_eq!(
transferred(&url, &to).as_deref(),
Some("https://g1t.sh/acme/booster.git/info/refs?service=git-upload-pack")
);
}
#[test]
fn head_follows_the_default_branch_in_a_v0_advertisement() {
let main = "1111111111111111111111111111111111111111";
let trunk = "2222222222222222222222222222222222222222";
let body = [
pkt("# service=git-upload-pack\n"),
b"0000".to_vec(),
pkt(&format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2\n")),
pkt(&format!("{main} refs/heads/main\n")),
pkt(&format!("{trunk} refs/heads/trunk\n")),
b"0000".to_vec(),
]
.concat();
let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
assert!(changed.contains(&format!("{trunk} HEAD\0multi_ack symref=HEAD:refs/heads/trunk agent=git/2\n")));
assert!(changed.contains(&format!("{main} refs/heads/main\n")));
assert!(changed.starts_with("001e# service=git-upload-pack\n0000"));
// Already right, or a branch it does not have: left alone.
assert!(with_head(&body, "main").is_none());
assert!(with_head(&body, "gone").is_none());
}
#[test]
fn head_follows_the_default_branch_in_a_v2_listing() {
let main = "1111111111111111111111111111111111111111";
let trunk = "2222222222222222222222222222222222222222";
let body = [
pkt(&format!("{main} HEAD symref-target:refs/heads/main\n")),
pkt(&format!("{main} refs/heads/main\n")),
pkt(&format!("{trunk} refs/heads/trunk\n")),
b"0000".to_vec(),
]
.concat();
let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
assert!(changed.starts_with(&String::from_utf8(pkt(&format!("{trunk} HEAD symref-target:refs/heads/trunk\n"))).unwrap()));
assert!(changed.ends_with("0000"));
// Without symrefs asked for, only the commit changes.
let plain = [pkt(&format!("{main} HEAD\n")), pkt(&format!("{trunk} refs/heads/trunk\n")), b"0000".to_vec()].concat();
let changed = String::from_utf8(with_head(&plain, "trunk").unwrap()).unwrap();
assert!(changed.starts_with(&format!("0032{trunk} HEAD\n")));
}
#[test]
fn a_push_is_measured_by_the_pack_after_its_commands() {
let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
let new = "4807077b296e6edbf410d55e72749d3e1170c291";
let pack = b"PACK\0\0\0\x02\0\0\0\0rest-of-pack";
let body = [
pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
b"0000".to_vec(),
pack.to_vec(),
]
.concat();
assert_eq!(pack_bytes(&body), pack.len() as u64);
// Only deletions: no pack.
let body = [pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")), b"0000".to_vec()].concat();
assert_eq!(pack_bytes(&body), 0);
assert_eq!(pack_bytes(b"garbage"), 0);
}
#[test]
fn a_transferred_repository_keeps_the_rest_of_the_address() {
let to = RepoPath {
namespace: "flagon-io".into(),
name: "g1t".into(),
};
let url = Url::parse("https://g1t.sh/syntaqx/g1t.git/info/refs?service=git-receive-pack").unwrap();
assert_eq!(
transferred(&url, &to).as_deref(),
Some("https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-receive-pack")
);
let url = Url::parse("https://g1t.sh/syntaqx/g1t/git-upload-pack").unwrap();
assert_eq!(
transferred(&url, &to).as_deref(),
Some("https://g1t.sh/flagon-io/g1t/git-upload-pack")
);
}
#[test]
fn a_renamed_workspace_keeps_the_rest_of_the_address() {
let url = worker::Url::parse(
"https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
)
.unwrap();
assert_eq!(
with_namespace(&url, "acme-inc").as_deref(),
Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
);
let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
assert_eq!(with_namespace(&bare, "acme-inc"), None);
}
fn pkt(payload: &str) -> Vec<u8> {
format!("{:04x}{payload}", payload.len() + 4).into_bytes()
}
#[test]
fn pushed_branches_are_read_from_the_commands() {
let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
let new = "4807077b296e6edbf410d55e72749d3e1170c291";
let body = [
pkt(&format!(
"{old} {new} refs/heads/main\0 report-status side-band-64k\n"
)),
pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
b"0000".to_vec(),
b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
]
.concat();
assert_eq!(
pushed_branches(&body),
[
Pushed {
git_ref: "refs/heads/main".to_owned(),
before: Some(old.to_owned()),
after: new.to_owned()
},
Pushed {
git_ref: "refs/heads/feature/x".to_owned(),
before: None,
after: new.to_owned()
},
Pushed {
git_ref: "refs/tags/v1".to_owned(),
before: None,
after: new.to_owned()
},
]
);
}
#[test]
fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
let new = "4807077b296e6edbf410d55e72749d3e1170c291";
let body = [
pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
b"0000".to_vec(),
]
.concat();
let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
assert!(report.starts_with("000eunpack ok\n"));
assert!(report.contains("ng refs/heads/main main is protected"));
assert!(report.contains("ng refs/heads/feature not pushed"));
assert!(report.ends_with("0000"));
}
#[test]
fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
let body = [
pkt(&format!(
"{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
)),
b"0000".to_vec(),
]
.concat();
let report = refusal(&body, "main").unwrap();
// A length, then channel 1, then the report itself.
assert_eq!(report[4], 1);
assert_eq!(&report[5..18], b"000eunpack ok");
assert!(report.ends_with(b"00000000"));
}
#[test]
fn other_branches_and_a_first_push_are_let_through() {
let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
let new = "4807077b296e6edbf410d55e72749d3e1170c291";
let feature = [
pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
b"0000".to_vec(),
]
.concat();
assert!(refusal(&feature, "main").is_none());
// An empty repository has to be able to receive its first commits.
let first = [
pkt(&format!(
"{ZERO_ID} {new} refs/heads/main\0 report-status\n"
)),
b"0000".to_vec(),
]
.concat();
assert!(refusal(&first, "main").is_none());
}
#[test]
fn a_blocked_push_explains_itself_on_the_progress_channel() {
let report = b"000eunpack ok\n0000".to_vec();
let messages = vec!["g1t found a secret in this push, so nothing was pushed.".to_owned()];
let body = framed(report.clone(), "report-status side-band-64k", &messages);
// Channel 2 first, which git prints as `remote:` lines.
assert_eq!(body[4], 2);
assert!(String::from_utf8_lossy(&body).contains("so nothing was pushed.\n"));
let at = body.windows(5).position(|w| w == b"000eu").unwrap();
assert_eq!(body[at - 1], 1);
assert!(body.ends_with(b"0000"));
// A client without side-band gets the bare report.
assert_eq!(framed(report.clone(), "report-status", &messages), report);
}
#[test]
fn a_fetch_request_names_no_branches() {
assert!(
pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
);
}
}