use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::protocol::pktline;
use crate::repository::Repository;
use crate::transport::Service;
use super::{advertise_refs, receive_pack, upload_pack};
pub const DEFAULT_MAX_BODY: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct ServerConfig {
pub max_body: usize,
pub allow_upload: bool,
pub allow_receive: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
max_body: DEFAULT_MAX_BODY,
allow_upload: true,
allow_receive: true,
}
}
}
impl ServerConfig {
pub fn read_only() -> Self {
ServerConfig {
allow_receive: false,
..ServerConfig::default()
}
}
}
pub struct HttpResponse {
pub status: u16,
pub content_type: String,
pub body: Vec<u8>,
}
impl HttpResponse {
fn text(status: u16, msg: &str) -> Self {
HttpResponse {
status,
content_type: "text/plain".to_string(),
body: msg.as_bytes().to_vec(),
}
}
}
pub fn handle_http(
repo: &Repository,
method: &str,
path: &str,
query: &str,
body: &[u8],
) -> HttpResponse {
handle_http_with(repo, &ServerConfig::default(), method, path, query, body)
}
pub fn handle_http_with(
repo: &Repository,
config: &ServerConfig,
method: &str,
path: &str,
query: &str,
body: &[u8],
) -> HttpResponse {
handle_http_with_version(repo, config, method, path, query, body, "")
}
pub fn handle_http_with_version(
repo: &Repository,
config: &ServerConfig,
method: &str,
path: &str,
query: &str,
body: &[u8],
git_protocol: &str,
) -> HttpResponse {
let path = path.trim_end_matches('/');
let v2 = git_protocol.split(':').any(|p| p.trim() == "version=2");
if method.eq_ignore_ascii_case("GET") && path.ends_with("/info/refs") {
let service = match service_from_query(query) {
Some(s) => s,
None => return HttpResponse::text(400, "missing or unknown service"),
};
if !service_allowed(config, service) {
return HttpResponse::text(403, "service not enabled");
}
if v2 && service == Service::UploadPack {
return v2_advertise(repo);
}
let v1 = git_protocol.split(':').any(|p| p.trim() == "version=1");
return info_refs(repo, service, v1);
}
if method.eq_ignore_ascii_case("POST") {
let service = if path.ends_with("/git-upload-pack") {
Some(Service::UploadPack)
} else if path.ends_with("/git-receive-pack") {
Some(Service::ReceivePack)
} else {
None
};
if let Some(service) = service {
if !service_allowed(config, service) {
return HttpResponse::text(403, "service not enabled");
}
if body.len() > config.max_body {
return HttpResponse::text(413, "request body too large");
}
if v2 && service == Service::UploadPack {
return v2_service_result(repo, body);
}
return service_result(repo, service, body);
}
}
HttpResponse::text(404, "not found")
}
fn v2_advertise(repo: &Repository) -> HttpResponse {
match super::v2_capability_advertisement(repo) {
Ok(body) => HttpResponse {
status: 200,
content_type: "application/x-git-upload-pack-advertisement".to_string(),
body,
},
Err(_) => HttpResponse::text(500, "internal error"),
}
}
fn v2_service_result(repo: &Repository, body: &[u8]) -> HttpResponse {
match super::serve_v2_upload_pack(repo, body) {
Ok(out) => HttpResponse {
status: 200,
content_type: "application/x-git-upload-pack-result".to_string(),
body: out,
},
Err(_) => HttpResponse::text(500, "internal error"),
}
}
fn service_allowed(config: &ServerConfig, service: Service) -> bool {
match service {
Service::UploadPack => config.allow_upload,
Service::ReceivePack => config.allow_receive,
}
}
fn info_refs(repo: &Repository, service: Service, v1: bool) -> HttpResponse {
let banner = alloc::format!("# service={}\n", service.name());
let mut body = match pktline::encode_data(banner.as_bytes()) {
Ok(b) => b,
Err(_) => return HttpResponse::text(500, "internal error"),
};
body.extend_from_slice(&pktline::encode_control(&pktline::Packet::Flush));
if v1 {
match pktline::encode_data(b"version 1\n") {
Ok(line) => body.extend_from_slice(&line),
Err(_) => return HttpResponse::text(500, "internal error"),
}
}
let caps: &[&str] = match service {
Service::UploadPack => super::UPLOAD_PACK_CAPABILITIES,
Service::ReceivePack => super::RECEIVE_PACK_CAPABILITIES,
};
match advertise_refs(repo, service, caps) {
Ok(adv) => body.extend_from_slice(&adv),
Err(_) => return HttpResponse::text(500, "internal error"),
}
HttpResponse {
status: 200,
content_type: alloc::format!("application/x-{}-advertisement", service.name()),
body,
}
}
fn service_result(repo: &Repository, service: Service, body: &[u8]) -> HttpResponse {
let result = match service {
Service::UploadPack => upload_pack(repo, body),
Service::ReceivePack => receive_pack(repo, body),
};
match result {
Ok(out) => HttpResponse {
status: 200,
content_type: alloc::format!("application/x-{}-result", service.name()),
body: out,
},
Err(_) => HttpResponse::text(500, "internal error"),
}
}
fn service_from_query(query: &str) -> Option<Service> {
for pair in query.split('&') {
if let Some(v) = pair.strip_prefix("service=") {
return match v {
"git-upload-pack" => Some(Service::UploadPack),
"git-receive-pack" => Some(Service::ReceivePack),
_ => None,
};
}
}
None
}
#[cfg(all(test, feature = "client"))]
mod tests {
use super::*;
use crate::object::Signature;
use crate::odb::ObjectDatabase;
fn sig() -> Signature {
Signature {
name: b"T".to_vec(),
email: b"t@e".to_vec(),
time: 0,
tz: b"+0000".to_vec(),
}
}
#[test]
fn info_refs_advertises_service_appropriate_capabilities() {
let dir = std::env::temp_dir().join(alloc::format!("puregit-caps-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let repo = Repository::init(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x\n").unwrap();
repo.add_path("a.txt").unwrap();
repo.commit(b"c\n", sig(), sig()).unwrap();
let up = handle_http(&repo, "GET", "/info/refs", "service=git-upload-pack", &[]);
assert!(
up.body.windows(9).any(|w| w == b"thin-pack"),
"upload-pack advert should offer thin-pack"
);
let rp = handle_http(&repo, "GET", "/info/refs", "service=git-receive-pack", &[]);
assert!(
rp.body.windows(13).any(|w| w == b"report-status"),
"receive-pack advert must offer report-status"
);
assert!(
rp.body.windows(11).any(|w| w == b"delete-refs"),
"receive-pack advert should offer delete-refs"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn http_info_refs_and_upload_pack_round_trip() {
let dir = std::env::temp_dir().join(alloc::format!("puregit-http-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let remote = Repository::init(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"hello\n").unwrap();
remote.add_path("a.txt").unwrap();
let tip = remote.commit(b"c\n", sig(), sig()).unwrap();
let adv = handle_http(
&remote,
"GET",
"/repo.git/info/refs",
"service=git-upload-pack",
&[],
);
assert_eq!(adv.status, 200);
assert_eq!(
adv.content_type,
"application/x-git-upload-pack-advertisement"
);
let packets = pktline::decode_all(&adv.body).unwrap();
assert!(matches!(&packets[0], pktline::Packet::Data(d) if d.starts_with(b"# service=")));
let mut req = crate::protocol::FetchRequest::default();
req.wants.push(tip);
let request = req.encode().unwrap();
let resp = handle_http(&remote, "POST", "/repo.git/git-upload-pack", "", &request);
assert_eq!(resp.status, 200);
assert_eq!(resp.content_type, "application/x-git-upload-pack-result");
let pack_off = resp.body.windows(4).position(|w| w == b"PACK").unwrap();
let dest = std::env::temp_dir().join(alloc::format!("puregit-http-dst-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dest);
std::fs::create_dir_all(&dest).unwrap();
let mut clone = Repository::init(&dest).unwrap();
clone.ingest_pack(&resp.body[pack_off..]).unwrap();
clone.reload_odb().unwrap();
assert!(clone.objects().contains(&tip));
assert_eq!(handle_http(&remote, "GET", "/nope", "", &[]).status, 404);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn oversized_body_is_rejected_413() {
let dir = std::env::temp_dir().join(alloc::format!("puregit-http-cap-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let repo = Repository::init(&dir).unwrap();
let config = ServerConfig {
max_body: 16,
..ServerConfig::default()
};
let big = alloc::vec![b'x'; 17];
let resp = handle_http_with(&repo, &config, "POST", "/r.git/git-upload-pack", "", &big);
assert_eq!(resp.status, 413);
let ok = alloc::vec![b'x'; 16];
let resp2 = handle_http_with(&repo, &config, "POST", "/r.git/git-upload-pack", "", &ok);
assert_ne!(resp2.status, 413);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn disabled_receive_pack_is_forbidden_403() {
let dir = std::env::temp_dir().join(alloc::format!("puregit-http-ro-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let repo = Repository::init(&dir).unwrap();
let config = ServerConfig::read_only();
let resp = handle_http_with(
&repo,
&config,
"POST",
"/r.git/git-receive-pack",
"",
b"anything",
);
assert_eq!(resp.status, 403);
let disc = handle_http_with(
&repo,
&config,
"GET",
"/r.git/info/refs",
"service=git-receive-pack",
&[],
);
assert_eq!(disc.status, 403);
let up = handle_http_with(
&repo,
&config,
"GET",
"/r.git/info/refs",
"service=git-upload-pack",
&[],
);
assert_eq!(up.status, 200);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn error_500_body_leaks_no_path_detail() {
let dir = std::env::temp_dir().join(alloc::format!("puregit-http-500-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let repo = Repository::init(&dir).unwrap();
let resp = handle_http(&repo, "POST", "/r.git/git-upload-pack", "", b"0009junk");
assert_eq!(resp.status, 500);
let body = core::str::from_utf8(&resp.body).unwrap();
assert_eq!(body, "internal error");
assert!(!body.contains('/'));
assert!(!body.contains("error:"));
let _ = std::fs::remove_dir_all(&dir);
}
}