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),
}
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"
),
}
}
}
#[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")
}
#[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}"),
);
}
};
if let Some(refusal) =
refusal_for_request(&path, &body, state.github.policy_rules(), &repository)
{
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
}
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;