use std::process::Stdio;
use std::sync::Arc;
use base64::Engine as _;
use jacquard_common::types::string::Nsid;
use knot_atproto::{ReplayGuard, ServiceJwt};
use knot_index::{Index, Resolved};
use knot_types::RepoDid;
use crate::cgi::{self, Request};
use crate::cob_write::WriteError;
use crate::config::ValidatedConfig;
const PUSH_NSID: &str = "org.tangled.git.receivePack";
const SERVICE: &str = "git-receive-pack";
const ADVERTISEMENT: &str = "application/x-git-receive-pack-advertisement";
const RESULT: &str = "application/x-git-receive-pack-result";
const DECLARATIONS: bool = false;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Route {
Advertise,
Receive,
}
pub fn route(request: &Request) -> Option<(Route, &str)> {
let (path, query) = request
.target
.split_once('?')
.unwrap_or((&request.target, ""));
let rest = path.strip_prefix('/')?;
match rest.split_once('/')? {
(did, "info/refs")
if query
.split('&')
.any(|pair| pair == "service=git-receive-pack") =>
{
Some((Route::Advertise, did))
}
(did, "git-receive-pack") => Some((Route::Receive, did)),
_ => None,
}
}
pub(super) fn credential(request: &Request) -> Result<ServiceJwt, WriteError> {
let unusable =
|| WriteError::auth_required("Authorization isn't a bearer token or basic credential");
let value = request
.authorization
.as_deref()
.ok_or_else(|| WriteError::auth_required("Missing authorization header"))?;
let (scheme, rest) = value.split_once(' ').ok_or_else(unusable)?;
let token = if scheme.eq_ignore_ascii_case("Bearer") {
rest.trim().to_owned()
} else if scheme.eq_ignore_ascii_case("Basic") {
let decoded = base64::engine::general_purpose::STANDARD
.decode(rest.trim())
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.ok_or_else(unusable)?;
let (_, password) = decoded.split_once(':').ok_or_else(unusable)?;
password.to_owned()
} else {
return Err(unusable());
};
ServiceJwt::new(token).map_err(|_| unusable())
}
pub async fn serve(
config: &Arc<ValidatedConfig>,
request: &Request,
(route, did): (Route, &str),
) -> Result<(&'static str, Vec<u8>), WriteError> {
cgi::ensure_enabled(config)?;
let (method, content_type) = match route {
Route::Advertise => ("GET", ADVERTISEMENT),
Route::Receive => ("POST", RESULT),
};
if request.method != method {
return Err(WriteError::unsupported(
http::StatusCode::METHOD_NOT_ALLOWED,
"InvalidRequest",
format!("this endpoint takes {method} requests"),
));
}
let not_found = || WriteError::not_found("repository not found");
let repo = RepoDid::new(did).map_err(|_| not_found())?;
// git sends the token twice, and the guard has to let it be reused until it expires.
let actor = cgi::atproto(config)?
.verify_service_jwt_guarded(
&credential(request)?,
&Nsid::new_owned(PUSH_NSID).expect("a valid nsid"),
ReplayGuard::ReusableUntilExpiry,
)
.await
.map_err(cgi::verify_failed)?;
let layout = config.layout().map_err(internal)?;
let index = Index::new(
&layout.meta_path(&config.knot_did).map_err(internal)?,
config.layout().map_err(internal)?,
);
index.rebuild()?;
if !matches!(index.owner_of(&repo), Resolved::Ready(Some(_))) {
return Err(not_found());
}
if !crate::git_ssh::may_push(config, &index, std::slice::from_ref(&actor), &repo)? {
return Err(WriteError::forbidden(
"you aren't authorized to push to this repository",
));
}
let path = layout.guarded_path(&repo).map_err(internal)?;
let mut git = crate::git_ssh::receive_pack(config);
git.arg("--stateless-rpc").arg(&path);
if route == Route::Advertise {
git.arg("--advertise-refs");
let output = git.stdin(Stdio::null()).output().map_err(internal)?;
if !output.status.success() {
return Err(internal(String::from_utf8_lossy(&output.stderr)));
}
let mut body = pkt_line(&format!("# service={SERVICE}\n"));
body.extend(b"0000");
body.extend(output.stdout);
return Ok((content_type, body));
}
let mut reply = Vec::new();
let (status, moved) = crate::git_ssh::receive_locked(config, &repo, &path, || {
let output = git
.stdin(Stdio::inherit())
.stderr(Stdio::inherit())
.output()?;
reply = output.stdout;
Ok(output.status)
})
.map_err(internal)?;
if !moved.is_empty() {
let changed = match crate::writes::publish_stored(config, &repo, None).await {
Ok(changed) => changed,
Err(error) => {
crate::spindle::defer(config, &repo, &actor, &moved).await;
return Err(error);
}
};
let pending = crate::spindle::defer(config, &repo, &actor, &moved).await;
crate::git_ssh::notify_post_activate(config, &changed, pending, DECLARATIONS);
}
if !status.success() {
return Err(internal(format!("git receive-pack exited with {status}")));
}
Ok((content_type, reply))
}
fn pkt_line(text: &str) -> Vec<u8> {
crate::git_export::pkt_line(text.as_bytes())
}
fn internal(error: impl std::fmt::Display) -> WriteError {
WriteError::internal(format!("{error:#}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_http_pushs_post_activate_skips_the_declaration() {
let repos = ["did:plc:abcdefghijklmnopqrstuvwx".to_string()];
assert_eq!(
crate::git_ssh::post_activate_args(&repos, DECLARATIONS),
[
"post-activate",
"--no-declarations",
"--repo",
repos[0].as_str()
]
);
assert_eq!(
crate::git_ssh::post_activate_args(&repos, true),
["post-activate", "--repo", repos[0].as_str()]
);
}
fn request(target: &str, authorization: Option<&str>) -> Request {
Request {
method: "GET".into(),
target: target.into(),
authorization: authorization.map(Into::into),
content_length: None,
garbled_length: false,
content_type: String::new(),
content_encoding: String::new(),
git_protocol: None,
inflating: false,
}
}
#[test]
fn only_receive_pack_routes_here() {
let routed = |target: &str| {
route(&request(target, None))
.map(|(route, did)| (route == Route::Receive, did.to_owned()))
};
assert_eq!(
routed("/did:plc:abc/info/refs?service=git-receive-pack"),
Some((false, "did:plc:abc".into()))
);
assert_eq!(
routed("/did:plc:abc/git-receive-pack"),
Some((true, "did:plc:abc".into()))
);
assert_eq!(
routed("/did:plc:abc/info/refs?service=git-upload-pack"),
None
);
assert_eq!(routed("/did:plc:abc/info/refs"), None);
assert_eq!(routed("/xrpc/com.atproto.repo.createRecord"), None);
}
#[test]
fn a_token_is_a_bearer_or_the_basic_password() {
use base64::Engine as _;
let basic = |pair: &str| {
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(pair)
)
};
let token = |header: &str| {
credential(&request("/", Some(header))).map(|token| token.as_str().to_owned())
};
let jwt = "aaa.bbb.ccc";
assert_eq!(token(&format!("Bearer {jwt}")).unwrap(), jwt);
assert_eq!(token(&basic(&format!("x:{jwt}"))).unwrap(), jwt);
assert!(token(&basic("x:")).is_err(), "an empty password");
assert!(token(&basic("no colon")).is_err());
assert!(token("Digest abc").is_err());
assert!(credential(&request("/", None)).is_err());
}
}