#![allow(dead_code)]
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::extract::{Request, State};
use axum::http::header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE, WWW_AUTHENTICATE};
use axum::http::{HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use git2::{BranchType, Commit, Oid, Repository, RepositoryInitOptions, Signature};
use ironflow_core::operation::{NoopSecretResolver, OperationContext};
use ironflow_ops_common::MapSecretResolver;
use tempfile::TempDir;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::process::Command;
use tokio::task::JoinHandle;
pub const TOKEN: &str = "glpat-test-token-0123456789";
pub const USERNAME: &str = "oauth2";
struct ServerConfig {
root: PathBuf,
username: String,
expected_auth: String,
}
pub struct GitHttpServer {
root: TempDir,
port: u16,
task: JoinHandle<()>,
}
impl GitHttpServer {
pub async fn start(username: &str, password: &str) -> Self {
let root = tempfile::tempdir().unwrap();
let expected_auth = format!(
"Basic {}",
STANDARD.encode(format!("{username}:{password}"))
);
let config = Arc::new(ServerConfig {
root: root.path().to_path_buf(),
username: username.to_string(),
expected_auth,
});
let app = Router::new().fallback(handle).with_state(config);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Self { root, port, task }
}
pub async fn start_default() -> Self {
Self::start(USERNAME, TOKEN).await
}
pub fn root(&self) -> &Path {
self.root.path()
}
pub fn url(&self, name: &str) -> String {
format!("http://127.0.0.1:{}/{name}", self.port)
}
pub fn url_with_userinfo(&self, name: &str, user: &str, token: &str) -> String {
format!("http://{user}:{token}@127.0.0.1:{}/{name}", self.port)
}
pub fn seed(&self, name: &str) -> Oid {
let bare = self.root().join(name);
init_bare_main(&bare);
let work = tempfile::tempdir().unwrap();
let oid = commit_on_main(work.path(), "file.txt", "content");
let repo = Repository::open(work.path()).unwrap();
let mut remote = repo.remote("seed", bare.to_str().unwrap()).unwrap();
remote
.push(&["refs/heads/main:refs/heads/main"], None)
.unwrap();
oid
}
pub fn create_branch(&self, name: &str, branch: &str, target: Oid) {
let repo = Repository::open_bare(self.root().join(name)).unwrap();
let commit = repo.find_commit(target).unwrap();
repo.branch(branch, &commit, false).unwrap();
}
pub fn delete_branch(&self, name: &str, branch: &str) {
let repo = Repository::open_bare(self.root().join(name)).unwrap();
repo.find_branch(branch, BranchType::Local)
.unwrap()
.delete()
.unwrap();
}
}
impl Drop for GitHttpServer {
fn drop(&mut self) {
self.task.abort();
}
}
async fn handle(State(config): State<Arc<ServerConfig>>, req: Request) -> Response {
let (parts, body) = req.into_parts();
let authorized = parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
== Some(config.expected_auth.as_str());
if !authorized {
return (
StatusCode::UNAUTHORIZED,
[(WWW_AUTHENTICATE, "Basic realm=\"git\"")],
)
.into_response();
}
let body = to_bytes(body, usize::MAX).await.unwrap();
let header = |name: HeaderName| {
parts
.headers
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string()
};
let mut child = Command::new("git")
.arg("http-backend")
.env("GIT_PROJECT_ROOT", &config.root)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("REQUEST_METHOD", parts.method.as_str())
.env("PATH_INFO", parts.uri.path())
.env("QUERY_STRING", parts.uri.query().unwrap_or(""))
.env("CONTENT_TYPE", header(CONTENT_TYPE))
.env("CONTENT_LENGTH", body.len().to_string())
.env("HTTP_CONTENT_ENCODING", header(CONTENT_ENCODING))
.env("REMOTE_USER", &config.username)
.env("REMOTE_ADDR", "127.0.0.1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let writer = tokio::spawn(async move {
stdin.write_all(&body).await.unwrap();
});
let output = child.wait_with_output().await.unwrap();
writer.await.unwrap();
cgi_response(&output.stdout)
}
fn cgi_response(stdout: &[u8]) -> Response {
let split = stdout
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|p| (p, p + 4))
.or_else(|| {
stdout
.windows(2)
.position(|w| w == b"\n\n")
.map(|p| (p, p + 2))
})
.expect("CGI output without header terminator");
let head = String::from_utf8_lossy(&stdout[..split.0]);
let mut status = StatusCode::OK;
let mut headers: HashMap<String, String> = HashMap::new();
for line in head.lines() {
let Some((name, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
if name.eq_ignore_ascii_case("status") {
let code = value.split_whitespace().next().unwrap();
status = StatusCode::from_u16(code.parse().unwrap()).unwrap();
} else {
headers.insert(name.to_string(), value.to_string());
}
}
let mut response = Response::new(Body::from(stdout[split.1..].to_vec()));
*response.status_mut() = status;
for (name, value) in headers {
response.headers_mut().insert(
HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(&value).unwrap(),
);
}
response
}
pub fn init_bare_main(path: &Path) -> Repository {
let mut opts = RepositoryInitOptions::new();
opts.bare(true).initial_head("main");
Repository::init_opts(path, &opts).unwrap()
}
pub fn commit_on_main(path: &Path, file: &str, content: &str) -> Oid {
let repo = match Repository::open(path) {
Ok(repo) => repo,
Err(_) => {
let mut opts = RepositoryInitOptions::new();
opts.initial_head("main");
Repository::init_opts(path, &opts).unwrap()
}
};
fs::write(path.join(file), content).unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new(file)).unwrap();
index.write().unwrap();
let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
let sig = Signature::now("Test", "test@test.com").unwrap();
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&Commit<'_>> = parent.iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, "commit", &tree, &parents)
.unwrap()
}
pub fn local_with_origin(url: &str) -> TempDir {
let work = tempfile::tempdir().unwrap();
commit_on_main(work.path(), "local.txt", "local");
Repository::open(work.path())
.unwrap()
.remote("origin", url)
.unwrap();
work
}
pub fn checkout_with_submodule(server: &GitHttpServer, url: &str) -> (TempDir, PathBuf) {
let dirs = tempfile::tempdir().unwrap();
let parent_path = dirs.path().join("parent");
commit_on_main(&parent_path, "README", "parent");
let mut parent = Repository::open(&parent_path).unwrap();
let local_sub = server.root().join("sub.git");
{
let mut sub = parent
.submodule(local_sub.to_str().unwrap(), Path::new("vendor/sub"), true)
.unwrap();
sub.clone(None).unwrap();
sub.add_finalize().unwrap();
}
parent.submodule_set_url("vendor/sub", url).unwrap();
let mut index = parent.index().unwrap();
index.add_path(Path::new(".gitmodules")).unwrap();
index.write().unwrap();
let tree = parent.find_tree(index.write_tree().unwrap()).unwrap();
let sig = Signature::now("Test", "test@test.com").unwrap();
let head = parent.head().unwrap().peel_to_commit().unwrap();
parent
.commit(Some("HEAD"), &sig, &sig, "add submodule", &tree, &[&head])
.unwrap();
let clone_path = dirs.path().join("checkout");
Repository::clone(parent_path.to_str().unwrap(), &clone_path).unwrap();
(dirs, clone_path)
}
pub fn ctx_with_secret(key: &str, value: &str) -> OperationContext {
let mut secrets = HashMap::new();
secrets.insert(key.to_string(), value.to_string());
OperationContext::new(Arc::new(MapSecretResolver::new(secrets)))
}
pub fn ctx_with_token() -> OperationContext {
ctx_with_secret("git_token", TOKEN)
}
pub fn ctx_without_secret() -> OperationContext {
OperationContext::new(Arc::new(NoopSecretResolver))
}