refactor(git/ssh): extract helper functions into dedicated modules
Move RefUpdate, GitService, branch_protection check, and forward function from handle.rs into separate modules.
This commit is contained in:
parent
deb25614ba
commit
3b17a0493f
67
libs/git/ssh/branch_protect.rs
Normal file
67
libs/git/ssh/branch_protect.rs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
use crate::ssh::ref_update::RefUpdate;
|
||||||
|
use models::repos::repo_branch_protect;
|
||||||
|
|
||||||
|
/// Ref name matches a protection rule exactly, or as a directory prefix
|
||||||
|
/// (e.g. "refs/heads/main" matches "refs/heads/main" and "refs/heads/main/*"
|
||||||
|
/// but NOT "refs/heads/main-v2").
|
||||||
|
fn ref_matches_protection(ref_name: &str, protection_branch: &str) -> bool {
|
||||||
|
ref_name == protection_branch
|
||||||
|
|| ref_name.starts_with(&format!("{}/", protection_branch))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Granular branch protection check (same logic as HTTP handler).
|
||||||
|
/// Returns `Some(error_message)` if the push should be rejected.
|
||||||
|
pub fn check_branch_protection(
|
||||||
|
branch_protects: &[repo_branch_protect::Model],
|
||||||
|
r#ref: &RefUpdate,
|
||||||
|
) -> Option<String> {
|
||||||
|
for protection in branch_protects {
|
||||||
|
if !ref_matches_protection(&r#ref.name, &protection.branch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check deletion (new_oid is all zeros)
|
||||||
|
if r#ref.new_oid == "0000000000000000000000000000000000000000" {
|
||||||
|
if protection.forbid_deletion {
|
||||||
|
return Some(format!(
|
||||||
|
"Deletion of protected branch '{}' is forbidden",
|
||||||
|
r#ref.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tag push
|
||||||
|
if r#ref.name.starts_with("refs/tags/") {
|
||||||
|
if protection.forbid_tag_push {
|
||||||
|
return Some(format!(
|
||||||
|
"Tag push to protected branch '{}' is forbidden",
|
||||||
|
r#ref.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check force push: old != new AND old is non-zero (non-fast-forward)
|
||||||
|
let is_new_branch = r#ref.old_oid == "0000000000000000000000000000000000000000";
|
||||||
|
if !is_new_branch
|
||||||
|
&& r#ref.old_oid != r#ref.new_oid
|
||||||
|
&& r#ref.name.starts_with("refs/heads/")
|
||||||
|
&& protection.forbid_force_push
|
||||||
|
{
|
||||||
|
return Some(format!(
|
||||||
|
"Force push to protected branch '{}' is forbidden",
|
||||||
|
r#ref.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check push
|
||||||
|
if protection.forbid_push {
|
||||||
|
return Some(format!(
|
||||||
|
"Push to protected branch '{}' is forbidden",
|
||||||
|
r#ref.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
50
libs/git/ssh/forward.rs
Normal file
50
libs/git/ssh/forward.rs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
use russh::server::Handle;
|
||||||
|
use russh::ChannelId;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tokio_util::bytes::Bytes;
|
||||||
|
|
||||||
|
pub async fn forward<'a, R, Fut, Fwd>(
|
||||||
|
session_handle: &'a Handle,
|
||||||
|
chan_id: ChannelId,
|
||||||
|
r: &mut R,
|
||||||
|
mut fwd: Fwd,
|
||||||
|
) -> Result<(), russh::Error>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Send + Unpin,
|
||||||
|
Fut: Future<Output = Result<(), Bytes>> + 'a,
|
||||||
|
Fwd: FnMut(&'a Handle, ChannelId, Bytes) -> Fut,
|
||||||
|
{
|
||||||
|
const BUF_SIZE: usize = 1024 * 32;
|
||||||
|
const MAX_RETRIES: usize = 5;
|
||||||
|
const RETRY_DELAY: u64 = 10; // ms
|
||||||
|
|
||||||
|
let mut buf = [0u8; BUF_SIZE];
|
||||||
|
loop {
|
||||||
|
let read = r.read(&mut buf).await?;
|
||||||
|
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk = Bytes::copy_from_slice(&buf[..read]);
|
||||||
|
let mut retries = 0;
|
||||||
|
loop {
|
||||||
|
match fwd(session_handle, chan_id, chunk).await {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(unsent) => {
|
||||||
|
retries += 1;
|
||||||
|
if retries >= MAX_RETRIES {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
chunk = unsent;
|
||||||
|
sleep(Duration::from_millis(RETRY_DELAY)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
82
libs/git/ssh/git_service.rs
Normal file
82
libs/git/ssh/git_service.rs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub enum GitService {
|
||||||
|
UploadPack,
|
||||||
|
ReceivePack,
|
||||||
|
UploadArchive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for GitService {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"upload-pack" => Ok(Self::UploadPack),
|
||||||
|
"receive-pack" => Ok(Self::ReceivePack),
|
||||||
|
"upload-archive" => Ok(Self::UploadArchive),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_git_command(cmd: &str) -> Option<(GitService, &str)> {
|
||||||
|
let (svc, path) = match cmd.split_once(' ') {
|
||||||
|
Some(("git-receive-pack", path)) => (GitService::ReceivePack, path),
|
||||||
|
Some(("git-upload-pack", path)) => (GitService::UploadPack, path),
|
||||||
|
Some(("git-upload-archive", path)) => (GitService::UploadArchive, path),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((svc, strip_apostrophes(path)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
|
||||||
|
let path = path.trim_matches('/');
|
||||||
|
let mut parts = path.splitn(2, '/');
|
||||||
|
match (parts.next(), parts.next()) {
|
||||||
|
(Some(owner), Some(repo)) if !owner.is_empty() && !repo.is_empty() => Some((owner, repo)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_git_command(service: GitService, path: PathBuf) -> tokio::process::Command {
|
||||||
|
let mut cmd = tokio::process::Command::new("git");
|
||||||
|
|
||||||
|
let cwd = match path.canonicalize() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(error = %e, "path canonicalize failed, falling back to raw path");
|
||||||
|
path.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cmd.current_dir(cwd);
|
||||||
|
|
||||||
|
match service {
|
||||||
|
GitService::UploadPack => { cmd.arg("upload-pack"); }
|
||||||
|
GitService::ReceivePack => { cmd.arg("receive-pack"); }
|
||||||
|
GitService::UploadArchive => { cmd.arg("upload-archive"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.arg(".")
|
||||||
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
.env("GIT_NO_REPLACE_OBJECTS", "1");
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||||
|
.env("GIT_CONFIG_SYSTEM", "/dev/null");
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let nul = "NUL";
|
||||||
|
cmd.env("GIT_CONFIG_GLOBAL", nul)
|
||||||
|
.env("GIT_CONFIG_SYSTEM", nul);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_apostrophes(s: &str) -> &str {
|
||||||
|
s.trim_matches('\'')
|
||||||
|
}
|
||||||
@ -7,8 +7,13 @@ use db::database::AppDatabase;
|
|||||||
use models::repos::{repo, repo_branch_protect};
|
use models::repos::{repo, repo_branch_protect};
|
||||||
use models::users::user;
|
use models::users::user;
|
||||||
use russh::keys::{Certificate, PublicKey};
|
use russh::keys::{Certificate, PublicKey};
|
||||||
use russh::server::{Auth, Handle, Msg, Session};
|
use russh::server::{Auth, Msg, Session};
|
||||||
use russh::{Channel, ChannelId, CryptoVec, Disconnect};
|
use russh::{Channel, ChannelId, Disconnect};
|
||||||
|
use crate::ssh::ref_update::RefUpdate;
|
||||||
|
use crate::ssh::git_service::{GitService, parse_git_command, parse_repo_path, build_git_command};
|
||||||
|
use crate::ssh::branch_protect::check_branch_protection;
|
||||||
|
use crate::ssh::forward::forward;
|
||||||
|
use tokio_util::bytes::Bytes;
|
||||||
use sea_orm::ColumnTrait;
|
use sea_orm::ColumnTrait;
|
||||||
use sea_orm::EntityTrait;
|
use sea_orm::EntityTrait;
|
||||||
use sea_orm::QueryFilter;
|
use sea_orm::QueryFilter;
|
||||||
@ -17,53 +22,13 @@ use std::io;
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::str::FromStr;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const PRE_PACK_LIMIT: usize = 1_048_576;
|
const PRE_PACK_LIMIT: usize = 1_048_576;
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::process::ChildStdin;
|
use tokio::process::ChildStdin;
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct RefUpdate {
|
|
||||||
pub name: String,
|
|
||||||
pub old_oid: String,
|
|
||||||
pub new_oid: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RefUpdate {
|
|
||||||
/// Parse git reference update commands from SSH protocol text.
|
|
||||||
/// Format: "<old-oid> <new-oid> <ref-name>\n"
|
|
||||||
pub fn parse_ref_updates(data: &[u8]) -> Result<Vec<Self>, String> {
|
|
||||||
let text = String::from_utf8_lossy(data);
|
|
||||||
let mut refs = Vec::new();
|
|
||||||
for line in text.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
if line.is_empty() || line.starts_with('#') || line.starts_with("PACK") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut parts = line.split_whitespace();
|
|
||||||
let old_oid = parts.next().map(|s| s.to_string()).unwrap_or_default();
|
|
||||||
let new_oid = parts.next().map(|s| s.to_string()).unwrap_or_default();
|
|
||||||
let name = parts
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim_start_matches('\0')
|
|
||||||
.to_string();
|
|
||||||
if !name.is_empty() {
|
|
||||||
refs.push(RefUpdate {
|
|
||||||
old_oid,
|
|
||||||
new_oid,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(refs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SSHandle {
|
pub struct SSHandle {
|
||||||
pub repo: Option<PathBuf>,
|
pub repo: Option<PathBuf>,
|
||||||
pub model: Option<repo::Model>,
|
pub model: Option<repo::Model>,
|
||||||
@ -379,7 +344,7 @@ impl russh::server::Handler for SSHandle {
|
|||||||
let _ = session.extended_data(
|
let _ = session.extended_data(
|
||||||
channel,
|
channel,
|
||||||
1,
|
1,
|
||||||
CryptoVec::from_slice(msg.as_bytes()),
|
Bytes::copy_from_slice(msg.as_bytes()),
|
||||||
);
|
);
|
||||||
let _ = session.exit_status_request(channel, 1);
|
let _ = session.exit_status_request(channel, 1);
|
||||||
let _ = session.eof(channel);
|
let _ = session.eof(channel);
|
||||||
@ -416,7 +381,7 @@ impl russh::server::Handler for SSHandle {
|
|||||||
let _ = session.extended_data(
|
let _ = session.extended_data(
|
||||||
channel,
|
channel,
|
||||||
1,
|
1,
|
||||||
CryptoVec::from_slice(full_msg.as_bytes()),
|
Bytes::copy_from_slice(full_msg.as_bytes()),
|
||||||
);
|
);
|
||||||
let _ = session.exit_status_request(channel, 1);
|
let _ = session.exit_status_request(channel, 1);
|
||||||
let _ = session.eof(channel);
|
let _ = session.eof(channel);
|
||||||
@ -480,7 +445,7 @@ impl russh::server::Handler for SSHandle {
|
|||||||
|
|
||||||
tracing::info!("shell_request user={}", user.username);
|
tracing::info!("shell_request user={}", user.username);
|
||||||
let _ = session
|
let _ = session
|
||||||
.data(channel_id, CryptoVec::from_slice(welcome_msg.as_bytes()));
|
.data(channel_id, Bytes::copy_from_slice(welcome_msg.as_bytes()));
|
||||||
let _ = session.exit_status_request(channel_id, 0);
|
let _ = session.exit_status_request(channel_id, 0);
|
||||||
let _ = session.eof(channel_id);
|
let _ = session.eof(channel_id);
|
||||||
let _ = session.close(channel_id);
|
let _ = session.close(channel_id);
|
||||||
@ -489,7 +454,7 @@ impl russh::server::Handler for SSHandle {
|
|||||||
tracing::warn!("shell_request_unauthenticated channel={:?}", channel_id);
|
tracing::warn!("shell_request_unauthenticated channel={:?}", channel_id);
|
||||||
let msg = "Authentication required\r\n";
|
let msg = "Authentication required\r\n";
|
||||||
let _ = session
|
let _ = session
|
||||||
.data(channel_id, CryptoVec::from_slice(msg.as_bytes()));
|
.data(channel_id, Bytes::copy_from_slice(msg.as_bytes()));
|
||||||
let _ = session.exit_status_request(channel_id, 1);
|
let _ = session.exit_status_request(channel_id, 1);
|
||||||
let _ = session.eof(channel_id);
|
let _ = session.eof(channel_id);
|
||||||
let _ = session.close(channel_id);
|
let _ = session.close(channel_id);
|
||||||
@ -733,200 +698,3 @@ impl russh::server::Handler for SSHandle {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_git_command(cmd: &str) -> Option<(GitService, &str)> {
|
|
||||||
let (svc, path) = match cmd.split_once(' ') {
|
|
||||||
Some(("git-receive-pack", path)) => (GitService::ReceivePack, path),
|
|
||||||
Some(("git-upload-pack", path)) => (GitService::UploadPack, path),
|
|
||||||
Some(("git-upload-archive", path)) => (GitService::UploadArchive, path),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some((svc, strip_apostrophes(path)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
|
|
||||||
let path = path.trim_matches('/');
|
|
||||||
let mut parts = path.splitn(2, '/');
|
|
||||||
match (parts.next(), parts.next()) {
|
|
||||||
(Some(owner), Some(repo)) if !owner.is_empty() && !repo.is_empty() => Some((owner, repo)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_git_command(service: GitService, path: PathBuf) -> tokio::process::Command {
|
|
||||||
let mut cmd = tokio::process::Command::new("git");
|
|
||||||
|
|
||||||
// Canonicalize only for validation; if it fails, fall back to the raw path.
|
|
||||||
// Using canonicalize for current_dir is safe since we validate repo existence
|
|
||||||
// before reaching this point.
|
|
||||||
let cwd = match path.canonicalize() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, "path canonicalize failed, falling back to raw path");
|
|
||||||
path.clone()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
cmd.current_dir(cwd);
|
|
||||||
|
|
||||||
match service {
|
|
||||||
GitService::UploadPack => { cmd.arg("upload-pack"); }
|
|
||||||
GitService::ReceivePack => { cmd.arg("receive-pack"); }
|
|
||||||
GitService::UploadArchive => { cmd.arg("upload-archive"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.arg(".")
|
|
||||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
||||||
.env("GIT_NO_REPLACE_OBJECTS", "1");
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
||||||
.env("GIT_CONFIG_SYSTEM", "/dev/null");
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
// On Windows, /dev/null doesn't exist. Set invalid paths so git
|
|
||||||
// ignores them without crashing. GIT_CONFIG_NOSYSTEM already disables
|
|
||||||
// the system config.
|
|
||||||
let nul = "NUL";
|
|
||||||
cmd.env("GIT_CONFIG_GLOBAL", nul)
|
|
||||||
.env("GIT_CONFIG_SYSTEM", nul);
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
fn strip_apostrophes(s: &str) -> &str {
|
|
||||||
s.trim_matches('\'')
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
||||||
pub enum GitService {
|
|
||||||
UploadPack,
|
|
||||||
ReceivePack,
|
|
||||||
UploadArchive,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for GitService {
|
|
||||||
type Err = ();
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
match s {
|
|
||||||
"upload-pack" => Ok(Self::UploadPack),
|
|
||||||
"receive-pack" => Ok(Self::ReceivePack),
|
|
||||||
"upload-archive" => Ok(Self::UploadArchive),
|
|
||||||
_ => Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ref name matches a protection rule exactly, or as a directory prefix
|
|
||||||
/// (e.g. "refs/heads/main" matches "refs/heads/main" and "refs/heads/main/*"
|
|
||||||
/// but NOT "refs/heads/main-v2").
|
|
||||||
fn ref_matches_protection(ref_name: &str, protection_branch: &str) -> bool {
|
|
||||||
ref_name == protection_branch
|
|
||||||
|| ref_name.starts_with(&format!("{}/", protection_branch))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Granular branch protection check (same logic as HTTP handler).
|
|
||||||
/// Returns `Some(error_message)` if the push should be rejected.
|
|
||||||
fn check_branch_protection(
|
|
||||||
branch_protects: &[repo_branch_protect::Model],
|
|
||||||
r#ref: &RefUpdate,
|
|
||||||
) -> Option<String> {
|
|
||||||
for protection in branch_protects {
|
|
||||||
if !ref_matches_protection(&r#ref.name, &protection.branch) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check deletion (new_oid is all zeros)
|
|
||||||
if r#ref.new_oid == "0000000000000000000000000000000000000000" {
|
|
||||||
if protection.forbid_deletion {
|
|
||||||
return Some(format!(
|
|
||||||
"Deletion of protected branch '{}' is forbidden",
|
|
||||||
r#ref.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check tag push
|
|
||||||
if r#ref.name.starts_with("refs/tags/") {
|
|
||||||
if protection.forbid_tag_push {
|
|
||||||
return Some(format!(
|
|
||||||
"Tag push to protected branch '{}' is forbidden",
|
|
||||||
r#ref.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check force push: old != new AND old is non-zero (non-fast-forward)
|
|
||||||
let is_new_branch = r#ref.old_oid == "0000000000000000000000000000000000000000";
|
|
||||||
if !is_new_branch
|
|
||||||
&& r#ref.old_oid != r#ref.new_oid
|
|
||||||
&& r#ref.name.starts_with("refs/heads/")
|
|
||||||
&& protection.forbid_force_push
|
|
||||||
{
|
|
||||||
return Some(format!(
|
|
||||||
"Force push to protected branch '{}' is forbidden",
|
|
||||||
r#ref.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check push
|
|
||||||
if protection.forbid_push {
|
|
||||||
return Some(format!(
|
|
||||||
"Push to protected branch '{}' is forbidden",
|
|
||||||
r#ref.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn forward<'a, R, Fut, Fwd>(
|
|
||||||
session_handle: &'a Handle,
|
|
||||||
chan_id: ChannelId,
|
|
||||||
r: &mut R,
|
|
||||||
mut fwd: Fwd,
|
|
||||||
) -> Result<(), russh::Error>
|
|
||||||
where
|
|
||||||
R: AsyncRead + Send + Unpin,
|
|
||||||
Fut: Future<Output = Result<(), CryptoVec>> + 'a,
|
|
||||||
Fwd: FnMut(&'a Handle, ChannelId, CryptoVec) -> Fut,
|
|
||||||
{
|
|
||||||
const BUF_SIZE: usize = 1024 * 32;
|
|
||||||
const MAX_RETRIES: usize = 5;
|
|
||||||
const RETRY_DELAY: u64 = 10; // ms
|
|
||||||
|
|
||||||
let mut buf = [0u8; BUF_SIZE];
|
|
||||||
loop {
|
|
||||||
let read = r.read(&mut buf).await?;
|
|
||||||
|
|
||||||
if read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut chunk = CryptoVec::from_slice(&buf[..read]);
|
|
||||||
let mut retries = 0;
|
|
||||||
loop {
|
|
||||||
match fwd(session_handle, chan_id, chunk).await {
|
|
||||||
Ok(()) => break,
|
|
||||||
Err(unsent) => {
|
|
||||||
retries += 1;
|
|
||||||
if retries >= MAX_RETRIES {
|
|
||||||
// Give up — connection is likely broken. Returning Ok (not Err)
|
|
||||||
// so the outer task can clean up gracefully without logging
|
|
||||||
// a spurious error for a normal disconnection.
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
chunk = unsent;
|
|
||||||
sleep(Duration::from_millis(RETRY_DELAY)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
@ -16,9 +16,13 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
pub mod authz;
|
pub mod authz;
|
||||||
|
pub mod branch_protect;
|
||||||
|
pub mod forward;
|
||||||
|
pub mod git_service;
|
||||||
pub mod handle;
|
pub mod handle;
|
||||||
pub mod server;
|
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
|
pub mod ref_update;
|
||||||
|
pub mod server;
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SSHHandle {
|
pub struct SSHHandle {
|
||||||
pub db: AppDatabase,
|
pub db: AppDatabase,
|
||||||
@ -106,7 +110,7 @@ impl SSHHandle {
|
|||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.keys = vec![private_key];
|
config.keys = vec![private_key];
|
||||||
let version = format!("SSH-2.0-GitdataAI {}", env!("CARGO_PKG_VERSION"));
|
let version = format!("SSH-2.0-GitdataAI {}", env!("CARGO_PKG_VERSION"));
|
||||||
config.server_id = SshId::Standard(version);
|
config.server_id = SshId::Standard(version.into());
|
||||||
let mut method = MethodSet::empty();
|
let mut method = MethodSet::empty();
|
||||||
method.push(MethodKind::PublicKey);
|
method.push(MethodKind::PublicKey);
|
||||||
method.push(MethodKind::KeyboardInteractive);
|
method.push(MethodKind::KeyboardInteractive);
|
||||||
|
|||||||
37
libs/git/ssh/ref_update.rs
Normal file
37
libs/git/ssh/ref_update.rs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RefUpdate {
|
||||||
|
pub name: String,
|
||||||
|
pub old_oid: String,
|
||||||
|
pub new_oid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RefUpdate {
|
||||||
|
/// Parse git reference update commands from SSH protocol text.
|
||||||
|
/// Format: "<old-oid> <new-oid> <ref-name>\n"
|
||||||
|
pub fn parse_ref_updates(data: &[u8]) -> Result<Vec<Self>, String> {
|
||||||
|
let text = String::from_utf8_lossy(data);
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') || line.starts_with("PACK") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let old_oid = parts.next().map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
let new_oid = parts.next().map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
let name = parts
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_start_matches('\0')
|
||||||
|
.to_string();
|
||||||
|
if !name.is_empty() {
|
||||||
|
refs.push(RefUpdate {
|
||||||
|
old_oid,
|
||||||
|
new_oid,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user