use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use crate::app_state::AppState;
use crate::github_proxy::POLICY_HEADER;
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefUpdate {
pub old: String,
pub new: String,
pub name: String,
}
impl RefUpdate {
#[must_use]
pub fn is_delete(&self) -> bool {
self.new == ZERO_OID
}
#[must_use]
pub fn is_create(&self) -> bool {
self.old == ZERO_OID
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefRefusal {
Delete(String),
NonFastForward(String),
Unverifiable(String, String),
}
impl RefRefusal {
#[must_use]
pub fn message(&self) -> String {
match self {
Self::Delete(name) => format!(
"Blocked by Link.Assistant.Router git policy: deleting {name} is refused; \
allow it in GITHUB_PROXY_POLICY to permit this ref"
),
Self::NonFastForward(name) => format!(
"Blocked by Link.Assistant.Router git policy: force-updating {name} is refused; \
allow it in GITHUB_PROXY_POLICY to permit this ref"
),
Self::Unverifiable(name, reason) => format!(
"Blocked by Link.Assistant.Router git policy: could not confirm {name} is a \
fast-forward ({reason}); allow it in GITHUB_PROXY_POLICY to permit this ref"
),
}
}
}
#[must_use]
pub fn parse_ref_updates(body: &[u8]) -> Vec<RefUpdate> {
let mut updates = Vec::new();
let mut cursor = 0usize;
while cursor + 4 <= body.len() {
let Ok(header) = std::str::from_utf8(&body[cursor..cursor + 4]) else {
break;
};
let Ok(length) = usize::from_str_radix(header, 16) else {
break;
};
if length == 0 {
break;
}
if length < 4 || cursor + length > body.len() {
break;
}
let payload = &body[cursor + 4..cursor + length];
cursor += length;
let line = payload.split(|byte| *byte == 0).next().unwrap_or(payload);
let Ok(line) = std::str::from_utf8(line) else {
continue;
};
let mut fields = line.trim().split(' ');
if let (Some(old), Some(new), Some(name)) = (fields.next(), fields.next(), fields.next())
&& old.len() == 40
&& new.len() == 40
{
updates.push(RefUpdate {
old: old.to_string(),
new: new.to_string(),
name: name.to_string(),
});
}
}
updates
}
#[must_use]
pub fn refuse_destructive_updates(
updates: &[RefUpdate],
forced: bool,
policy: &crate::github_proxy::GitHubPolicy,
repository: &str,
) -> Option<RefRefusal> {
for update in updates {
let allowed = policy.allows_git_ref(repository, &update.name);
if update.is_delete() {
if allowed {
continue;
}
return Some(RefRefusal::Delete(update.name.clone()));
}
if forced && !update.is_create() && !allowed {
return Some(RefRefusal::NonFastForward(update.name.clone()));
}
}
None
}
#[must_use]
pub fn body_requests_force(body: &[u8]) -> bool {
let window = &body[..body.len().min(4096)];
let Some(start) = window.iter().position(|byte| *byte == 0) else {
return false;
};
let tail = String::from_utf8_lossy(&window[start..]);
tail.contains("force-ref-updates") || tail.contains("push-force")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AncestryQuery {
pub name: String,
pub old: String,
pub new: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ancestry {
FastForward,
Diverged,
Unknown,
}
#[must_use]
pub fn ancestry_from_compare(payload: &serde_json::Value) -> Ancestry {
match payload.get("status").and_then(serde_json::Value::as_str) {
Some("ahead" | "identical") => Ancestry::FastForward,
Some("behind" | "diverged") => Ancestry::Diverged,
_ => Ancestry::Unknown,
}
}
#[must_use]
pub fn updates_needing_ancestry(
updates: &[RefUpdate],
policy: &crate::github_proxy::GitHubPolicy,
repository: &str,
) -> Vec<AncestryQuery> {
updates
.iter()
.filter(|update| !update.is_create() && !update.is_delete())
.filter(|update| !policy.allows_git_ref(repository, &update.name))
.map(|update| AncestryQuery {
name: update.name.clone(),
old: update.old.clone(),
new: update.new.clone(),
})
.collect()
}
#[must_use]
pub fn repository_in_git_path(path: &str) -> Option<String> {
let rest = path.strip_prefix("/git/")?;
let mut parts = rest.split('/');
let owner = parts.next().filter(|part| !part.is_empty())?;
let repo = parts.next().filter(|part| !part.is_empty())?;
Some(format!("{owner}/{}", repo.trim_end_matches(".git")))
}
#[must_use]
pub fn upstream_git_url(base: &str, path: &str, query: Option<&str>) -> Option<String> {
let rest = path.strip_prefix("/git/")?;
let mut url = format!("{}/{rest}", base.trim_end_matches('/'));
if let Some(query) = query {
url.push('?');
url.push_str(query);
}
Some(url)
}
#[must_use]
pub fn refusal_for_request(
path: &str,
body: &[u8],
policy: &crate::github_proxy::GitHubPolicy,
repository: &str,
) -> Option<RefRefusal> {
if !path.ends_with("/git-receive-pack") {
return None;
}
refuse_destructive_updates(
&parse_ref_updates(body),
body_requests_force(body),
policy,
repository,
)
}
#[must_use]
pub fn scope_admits(allowed_repositories: &[String], repository: &str) -> bool {
allowed_repositories.is_empty()
|| allowed_repositories
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(repository))
}
pub async fn proxy(State(state): State<AppState>, request: Request) -> Response {
let scope = crate::proxy::authenticate_client_error(&state, request.headers())
.map(|claims| claims.github_repos)
.unwrap_or_default();
forward(&state, &scope, request).await
}
async fn forward(state: &AppState, allowed_repositories: &[String], request: Request) -> Response {
let Some(token) = state.github.credential() else {
return git_error(
StatusCode::SERVICE_UNAVAILABLE,
"GitHub proxy is not configured",
);
};
let (parts, body) = request.into_parts();
let path = parts.uri.path().to_string();
let Some(repository) = repository_in_git_path(&path) else {
return git_error(StatusCode::NOT_FOUND, "not a git repository path");
};
if !scope_admits(allowed_repositories, &repository) {
return blocked("outside this token's repositories");
}
let body = match axum::body::to_bytes(body, state.max_proxy_request_bytes).await {
Ok(body) => body,
Err(error) => {
return git_error(
StatusCode::PAYLOAD_TOO_LARGE,
&format!("request body exceeds the proxy limit: {error}"),
);
}
};
let mut decision = refusal_for_request(&path, &body, state.github.policy_rules(), &repository);
if decision.is_none() && path.ends_with("/git-receive-pack") {
decision = refuse_rewrites_upstream(state, token, &repository, &body).await;
}
if let Some(refusal) = decision {
state.request_log.record(
&crate::request_log::correlation_id(&parts.headers),
"git_policy_refusal",
serde_json::json!({
"repository": repository,
"refusal": refusal.message(),
}),
);
return blocked(&refusal.message());
}
let Some(url) = upstream_git_url(&state.github.git_base_url(), &path, parts.uri.query()) else {
return git_error(StatusCode::NOT_FOUND, "not a git repository path");
};
let mut upstream = state
.client
.request(parts.method.clone(), url)
.basic_auth("x-access-token", Some(token));
for header in ["content-type", "accept", "user-agent", "git-protocol"] {
if let Some(value) = parts.headers.get(header) {
upstream = upstream.header(header, value.clone());
}
}
let response = match upstream.body(body.to_vec()).send().await {
Ok(response) => response,
Err(error) => {
return git_error(
StatusCode::BAD_GATEWAY,
&format!("git upstream request failed: {error}"),
);
}
};
let status = StatusCode::from_u16(response.status().as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let headers = crate::proxy::relay_response_headers(response.headers());
let payload = response.bytes().await.unwrap_or_default();
let mut relayed = Response::new(Body::from(payload));
*relayed.status_mut() = status;
*relayed.headers_mut() = headers;
relayed
}
async fn refuse_rewrites_upstream(
state: &AppState,
token: &str,
repository: &str,
body: &[u8],
) -> Option<RefRefusal> {
let updates = parse_ref_updates(body);
let queries = updates_needing_ancestry(&updates, state.github.policy_rules(), repository);
for query in queries {
let url = format!(
"{}/repos/{repository}/compare/{}...{}",
state.github.base_url.trim_end_matches('/'),
query.old,
query.new
);
let response = state
.client
.get(&url)
.basic_auth("x-access-token", Some(token))
.header("accept", "application/vnd.github+json")
.header("user-agent", "link-assistant-router")
.send()
.await;
let ancestry = match response {
Ok(response) if response.status().is_success() => response
.json::<serde_json::Value>()
.await
.map_or(Ancestry::Unknown, |payload| ancestry_from_compare(&payload)),
Ok(response) => {
return Some(RefRefusal::Unverifiable(
query.name,
format!("upstream answered {}", response.status()),
));
}
Err(error) => {
return Some(RefRefusal::Unverifiable(
query.name,
format!("upstream request failed: {error}"),
));
}
};
match ancestry {
Ancestry::FastForward => {}
Ancestry::Diverged => return Some(RefRefusal::NonFastForward(query.name)),
Ancestry::Unknown => {
return Some(RefRefusal::Unverifiable(
query.name,
"upstream gave no comparable status".into(),
));
}
}
}
None
}
fn blocked(message: &str) -> Response {
let mut response = git_error(StatusCode::FORBIDDEN, message);
response
.headers_mut()
.insert(POLICY_HEADER, HeaderValue::from_static("blocked"));
response
}
fn git_error(status: StatusCode, message: &str) -> Response {
(status, format!("{message}\n")).into_response()
}
#[cfg(test)]
#[path = "git_proxy_tests.rs"]
mod tests;