use tokio::io::{AsyncRead as TokioAsyncRead, AsyncReadExt};
use crate::registry::{validate_repo_id, RepoIdError};
use crate::substrate::Service;
pub const MAX_REQUEST_LINE_BYTES: usize = 4096;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestLine {
service: Service,
repo: String,
}
impl RequestLine {
pub fn service(&self) -> Service {
self.service
}
pub fn repo(&self) -> &str {
&self.repo
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum RequestLineError {
#[error("malformed request line: {0}")]
Malformed(String),
#[error("unknown service: {0}")]
ServiceUnknown(String),
#[error("invalid repo id {repo:?}: {cause}")]
RepoInvalid { repo: String, cause: RepoIdError },
#[error(
"only protocol v2 is served for fetch — the request line must carry version=2 (ADR-003)"
)]
VersionDeclined { token: Option<String> },
}
impl RequestLineError {
pub fn decline_message(&self) -> Option<String> {
match self {
RequestLineError::Malformed(why) => {
Some(format!("malformed request line: {why}"))
}
RequestLineError::ServiceUnknown(service) => {
Some(format!("unknown service: {service}"))
}
RequestLineError::RepoInvalid { .. } => None,
RequestLineError::VersionDeclined { token: None } => Some(
"only protocol v2 is served for fetch — the request line must carry version=2 (ADR-003)"
.to_owned(),
),
RequestLineError::VersionDeclined { token: Some(token) } => Some(format!(
"only protocol v2 is served for fetch — the request line must carry version=2 \
(unrecognized version token: {token})"
)),
}
}
}
pub fn parse_request_line(payload: &str) -> Result<RequestLine, RequestLineError> {
let (body, extras) = payload.split_once('\0').unwrap_or((payload, ""));
let (service_str, repo) = body
.split_once(' ')
.ok_or_else(|| RequestLineError::Malformed("expected \"<service> <repo>\"".to_owned()))?;
let service = service_str
.parse::<Service>()
.map_err(|_| RequestLineError::ServiceUnknown(service_str.to_owned()))?;
validate_repo_id(repo).map_err(|cause| RequestLineError::RepoInvalid {
repo: repo.to_owned(),
cause,
})?;
let mut version_token: Option<String> = None;
for extra in extras.split('\0') {
if extra.is_empty() {
continue;
}
if extra.starts_with("host=") {
continue;
}
if let Some(token) = extra.strip_prefix("version=") {
version_token = Some(token.to_owned());
}
}
match service {
Service::ReceivePack => {}
Service::UploadPack => match version_token.as_deref() {
Some("2") => {}
other => {
return Err(RequestLineError::VersionDeclined {
token: other.map(str::to_owned),
})
}
},
}
Ok(RequestLine {
service,
repo: repo.to_owned(),
})
}
pub async fn read_request_line<T>(stream: &mut T) -> Result<Option<RequestLine>, RequestLineError>
where
T: TokioAsyncRead + Unpin,
{
let mut header = [0u8; 4];
let mut filled = 0;
loop {
match stream.read(&mut header[filled..]).await {
Ok(0) => {
return if filled == 0 {
Ok(None)
} else {
Err(RequestLineError::Malformed(
"the pkt-line header was truncated".to_owned(),
))
};
}
Ok(n) => filled += n,
Err(e) => {
return Err(RequestLineError::Malformed(format!(
"the request-line read failed: {e}"
)))
}
}
if filled == header.len() {
break;
}
}
let total = usize::from_str_radix(
std::str::from_utf8(&header)
.map_err(|_| RequestLineError::Malformed("non-UTF-8 pkt-line header".to_owned()))?,
16,
)
.map_err(|_| RequestLineError::Malformed("non-hex pkt-line header length".to_owned()))?;
if total < 4 {
return Err(RequestLineError::Malformed(format!(
"invalid pkt-line length {total}"
)));
}
let line_bytes = total - 4;
if line_bytes > MAX_REQUEST_LINE_BYTES {
return Err(RequestLineError::Malformed(format!(
"the request line exceeds the {MAX_REQUEST_LINE_BYTES}-byte cap"
)));
}
let mut payload = vec![0u8; line_bytes];
stream
.read_exact(&mut payload)
.await
.map_err(|_| RequestLineError::Malformed("the request line was truncated".to_owned()))?;
let text = String::from_utf8(payload).map_err(|_| {
RequestLineError::Malformed("the request line payload is not UTF-8".to_owned())
})?;
parse_request_line(&text).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_fetch_request_line_parses_with_host_and_version_extras() {
let line =
parse_request_line("git-upload-pack owner/doubled\0host=example.com\0\0version=2\0")
.unwrap();
assert_eq!(line.service(), Service::UploadPack);
assert_eq!(line.repo(), "owner/doubled");
}
#[test]
fn the_push_request_line_parses_without_a_version_token() {
let line =
parse_request_line("git-receive-pack owner/doubled\0host=example.com\0").unwrap();
assert_eq!(line.service(), Service::ReceivePack);
assert_eq!(line.repo(), "owner/doubled");
}
#[test]
fn a_service_repo_line_without_extras_parses() {
let push = parse_request_line("git-receive-pack owner/doubled").unwrap();
assert_eq!(push.repo(), "owner/doubled");
assert!(parse_request_line("git-upload-pack owner/doubled").is_err());
}
#[test]
fn extras_are_accepted_verbatim() {
let line = parse_request_line(
"git-upload-pack owner/doubled\0host=example.com\0\0version=2\0extra=whatever\0",
)
.unwrap();
assert_eq!(line.repo(), "owner/doubled");
}
#[test]
fn an_unknown_service_fails() {
for line in [
"git-upload-archive owner/doubled\0host=x",
"nope owner/repo",
] {
let error = parse_request_line(line).unwrap_err();
assert!(
matches!(error, RequestLineError::ServiceUnknown(_)),
"{line}: {error}"
);
assert!(
error.decline_message().is_some(),
"the decline is the clear error"
);
}
}
#[test]
fn a_grammar_invalid_repo_id_fails_as_the_collapsed_denial() {
for repo in ["", "UPPER/x", "owner/", ".", "owner/n a m e"] {
let payload = format!("git-upload-pack {repo}\0\0version=2");
let error = parse_request_line(&payload).unwrap_err();
match &error {
RequestLineError::RepoInvalid { repo: id, .. } => {
assert_eq!(id, repo);
}
other => panic!("{repo}: expected RepoInvalid, got {other:?}"),
}
assert!(error.decline_message().is_none());
}
}
#[test]
fn a_fetch_line_without_or_with_an_unrecognized_version_token_declines() {
let without = parse_request_line("git-upload-pack owner/doubled\0host=x\0").unwrap_err();
assert!(matches!(
without,
RequestLineError::VersionDeclined { token: None }
));
let unrecognized =
parse_request_line("git-upload-pack owner/doubled\0host=x\0version=1").unwrap_err();
match &unrecognized {
RequestLineError::VersionDeclined { token } => {
assert_eq!(token.as_deref(), Some("1"));
}
other => panic!("expected VersionDeclined, got {other:?}"),
}
assert!(without.decline_message().is_some());
}
#[test]
fn deformed_lines_are_malformed() {
for payload in ["git-upload-pack", "", "\0host=x\0version=2"] {
assert!(
matches!(
parse_request_line(payload),
Err(RequestLineError::Malformed(_))
),
"{payload:?} is malformed"
);
}
}
#[tokio::test]
async fn the_wire_reader_serves_the_full_frame_and_the_quiet_close() {
use tokio::io::AsyncWriteExt;
let (mut client, mut server) = tokio::io::duplex(64);
let payload = "git-receive-pack owner/doubled\0host=x\0";
let mut frame = format!("{:04x}", payload.len() + 4).into_bytes();
frame.extend_from_slice(payload.as_bytes());
let driver = tokio::spawn(async move {
client.write_all(&frame[..2]).await.unwrap();
client.write_all(&frame[2..4]).await.unwrap();
client.write_all(&frame[4..]).await.unwrap();
client.shutdown().await.unwrap();
});
let line = read_request_line(&mut server)
.await
.unwrap()
.expect("the line parses once the frame is whole");
assert_eq!(line.repo(), "owner/doubled");
assert_eq!(line.service(), Service::ReceivePack);
driver.await.unwrap();
let (mut client, mut server) = tokio::io::duplex(64);
client.shutdown().await.unwrap();
drop(client);
assert!(read_request_line(&mut server).await.unwrap().is_none());
}
#[tokio::test]
async fn the_wire_reader_fails_closed_on_malformed_frames() {
use tokio::io::AsyncWriteExt;
for (why, bytes, expected) in [
("a truncated header", &b"001"[..], "header was truncated"),
("a non-hex pkt-line header", &b"zzzz"[..], "non-hex"),
(
"a below-minimum length",
&b"0002"[..],
"invalid pkt-line length",
),
("a header over the byte cap", &b"ffff"[..], "byte cap"),
(
"a truncated payload",
&b"0010\x01\x02\x03\x04\x05"[..],
"the request line was truncated",
),
("a non-UTF-8 payload", &b"0006\xff\xfe"[..], "not UTF-8"),
] {
let (mut client, mut server) = tokio::io::duplex(64);
client.write_all(bytes).await.unwrap();
client.shutdown().await.unwrap();
drop(client);
match read_request_line(&mut server).await {
Err(RequestLineError::Malformed(message)) => {
assert!(message.contains(expected), "{why}: {message}");
}
other => panic!("{why}: expected Malformed, got {other:?}"),
}
}
}
}