2ae9196
ZhenYi
feat(lib/git): add HTTP handler tree with routes and errors across analysis, archives, blobs, branches, commits, conflicts, content, diffs, hooks, lfs, logs, maintenance, packs, patches, pipelines, refs, remotes, repos, signatures, submodules, tags, trees
2ae9196
use bytes::Bytes;
use russh::ChannelId;
use russh::server::Session;
use sea_orm::EntityTrait;
use uuid::Uuid;
use crate::share::access::authorize_repo_access;
use crate::share::errors::GitError;
use crate::smart::error::SmartError;
use crate::ssh::error::SshError;
use crate::ssh::git_service::{
GitService, build_git_advertise_command, parse_git_command, parse_repo_path, sanitize_for_log,
};
use crate::ssh::handler::GitSshHandler;
use crate::ssh::handler::git_op::GitOperation;
impl GitSshHandler {
pub async fn exec_request_impl(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), russh::Error> {
let cmd = match std::str::from_utf8(data) {
Ok(c) => c,
Err(_) => {
invalid_command("invalid command encoding", session, channel).await;
return Ok(());
}
};
let cmd = cmd.trim();
tracing::info!(
"ssh exec from {:?}: {}",
self.client_addr,
sanitize_for_log(cmd)
);
let Some((service, raw_path)) = parse_git_command(cmd) else {
invalid_command("invalid git command", session, channel).await;
return Ok(());
};
let Some((space_name, repo_name)) = parse_repo_path(raw_path) else {
invalid_command("invalid repository path", session, channel).await;
return Ok(());
};
let actor_id = self.operator.as_ref().map(|u| u.uid);
let required = if service.is_write() {
"write"
} else {
"read"
};
if let Err(msg) = self
.authorize_repo(space_name, repo_name, service.is_write())
.await
{
tracing::warn!("ssh {service:?} {space_name}/{repo_name} denied: {msg}");
invalid_command(&msg, session, channel).await;
return Ok(());
}
let ctx = match self
.service
.smart
.resolve(space_name, repo_name)
.await
{
Ok(c) => c,
Err(e) => {
let msg = format!("fatal: {e}");
invalid_command(&msg, session, channel).await;
return Ok(());
}
};
let space_id = match self.space_id_for(&ctx.model.dir_id).await {
Ok(id) => id,
Err(_) => ctx.model.dir_id,
};
let bare = ctx.repo_dir();
if !bare.is_dir() {
let msg = format!(
"fatal: repository '{space_name}/{repo_name}' does not exist"
);
invalid_command(&msg, session, channel).await;
return Ok(());
}
let adv = match run_advertise(&bare, service).await {
Ok(adv) => adv,
Err(e) => {
let msg = format!("fatal: unable to read repository advertisement: {e}");
invalid_command(&msg, session, channel).await;
return Ok(());
}
};
if !adv.is_empty() {
let _ = session.data(channel, Bytes::from(adv));
}
let _ = session.channel_success(channel);
let _ = session.flush();
let op = match service {
GitService::UploadPack => GitOperation::UploadPack { bare: bare.clone() },
GitService::UploadArchive => GitOperation::UploadArchive { bare: bare.clone() },
GitService::ReceivePack => GitOperation::ReceivePack { bare: bare.clone() },
};
let state = self.channels.entry(channel).or_default();
state.git_op = Some(op);
state.repo_id = Some(ctx.model.id);
state.space_id = Some(space_id);
state.actor_id = actor_id;
let _ = required;
Ok(())
}
async fn authorize_repo(
&self,
space: &str,
repo: &str,
want_write: bool,
) -> Result<(), String> {
let svc = &self.service.smart.git;
let ctx = svc
.context(space.to_string(), repo.to_string())
.await
.map_err(|e| format!("{e}"))?;
let Some(operator) = &self.operator else {
return Err("authentication required".into());
};
authorize_repo_access(svc, operator.uid, &ctx, want_write, false)
.await
.map_err(|e| match e {
GitError::Forbidden => "permission denied".to_string(),
other => format!("{other}"),
})
}
async fn space_id_for(&self, dir_id: &Uuid) -> Result<Uuid, SmartError> {
let svc = &self.service.smart.git;
let mut current = *dir_id;
loop {
let dir = models::dir::dirs::Entity::find_by_id(current)
.one(svc.db_read())
.await
.map_err(SmartError::Db)?;
let dir = dir.ok_or_else(|| SmartError::NotFound(format!("dir {current}")))?;
if let Some(space_id) = dir.space_id {
return Ok(space_id);
}
match dir.parent_dir_id {
Some(parent) => current = parent,
None => return Err(SmartError::NotFound("space".into())),
}
}
}
pub async fn shell_request_impl(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), russh::Error> {
let _ = session.data(channel, Bytes::from_static(
b"Welcome to GitData SSH. This is a git-only server; shell access is disabled.\r\n",
));
let _ = session.exit_status_request(channel, 0);
let _ = session.eof(channel);
let _ = session.close(channel);
let _ = session.flush();
Ok(())
}
}
async fn invalid_command(msg: &str, session: &mut Session, channel: ChannelId) {
let _ = session.data(channel, Bytes::from(format!("{msg}\n").into_bytes()));
let _ = session.channel_failure(channel);
let _ = session.exit_status_request(channel, 1);
let _ = session.eof(channel);
let _ = session.close(channel);
let _ = session.flush();
}
async fn run_advertise(
bare: &std::path::Path,
service: GitService,
) -> Result<Vec<u8>, SshError> {
let mut cmd = build_git_advertise_command(service, bare);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(SshError::Io)?;
let stderr_task = child.stderr.take().map(|stderr| {
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let mut s = stderr;
let mut buf = Vec::new();
let _ = s.read_to_end(&mut buf).await;
buf
})
});
let mut stdout = child
.stdout
.take()
.ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?;
let mut out = Vec::new();
use tokio::io::AsyncReadExt;
stdout.read_to_end(&mut out).await.map_err(SshError::Io)?;
let status = child.wait().await.map_err(SshError::Io)?;
let stderr_bytes = match stderr_task {
Some(t) => t.await.unwrap_or_default(),
None => Vec::new(),
};
if !status.success() {
return Err(SshError::Internal(format!(
"git {} --advertise-refs failed: {}",
service.as_subcommand(),
String::from_utf8_lossy(&stderr_bytes)
)));
}
Ok(out)
}
#[allow(dead_code)]
fn _keep_typed(_: GitError) {}