use bytes::Bytes; use russh::ChannelId; use russh::server::Session; use gitauth::ResolvedRepo; use repo::actor::{ActorContext, RepoAccess}; use crate::error::SshError; use crate::git_service::{GitService, parse_git_command, parse_repo_path, sanitize_for_log}; use crate::handler::GitSshHandler; use crate::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 mut service_label: &'static str = "unknown"; let result: Result<(), russh::Error> = async { let cmd = std::str::from_utf8(data) .map_err(|_| invalid_command_error("Invalid command encoding", session, channel))?; 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 { return Err(invalid_command_error( "Invalid git command", session, channel, )); }; let Some((space, repo)) = parse_repo_path(raw_path) else { return Err(invalid_command_error( "Invalid repository path", session, channel, )); }; service_label = service.as_subcommand(); let actor = match &self.operator { Some(u) => ActorContext::for_user(u.uid), None => ActorContext::anonymous(), }; let required = if service.is_write() { RepoAccess::Write } else { RepoAccess::Read }; let resolved: ResolvedRepo = match self .service .auth .authorize(space, repo, &actor, required) .await { Ok(r) => r, Err(e) => { let msg = auth_error_message(&e); tracing::warn!("ssh {service:?} {space}/{repo} denied: {msg}"); return Err(invalid_command_error(msg.as_str(), session, channel)); } }; let bare = self.service.bare_path(&resolved); if !bare.is_dir() { let msg = format!("fatal: repository '{space}/{repo}' does not exist"); return Err(invalid_command_error(msg.as_str(), session, channel)); } let adv = run_advertise(&bare, service).await.unwrap_or_default(); 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() }, _ => GitOperation::ReceivePack { bare: bare.clone() }, }; let state = self.channels.entry(channel).or_default(); state.git_op = Some(op); state.repo_id = Some(resolved.repo.id); state.channel_id = Some(resolved.channel.id); state.actor_id = actor.user_id; Ok(()) } .await; crate::metrics::record_exec(service_label, result.is_ok()); result } } fn invalid_command_error(msg: &str, session: &mut Session, channel: ChannelId) -> russh::Error { let _ = session.channel_failure(channel); let _ = session.exit_status_request(channel, 1); let _ = session.close(channel); let _ = session.flush(); russh::Error::IO(std::io::Error::new( std::io::ErrorKind::InvalidData, msg.to_string(), )) } fn auth_error_message(e: &gitauth::GitAuthError) -> String { match e { gitauth::GitAuthError::BadRequest(m) | gitauth::GitAuthError::Unauthorized(m) | gitauth::GitAuthError::Forbidden(m) | gitauth::GitAuthError::NotFound(m) | gitauth::GitAuthError::Conflict(m) => m.clone(), _ => "authentication or authorization failed".to_string(), } } async fn run_advertise( bare: &std::path::PathBuf, service: GitService, ) -> Result, SshError> { let mut cmd = tokio::process::Command::new("git"); cmd.current_dir(bare) .arg(service.as_subcommand()) .arg("--advertise-refs") .env("GIT_DIR", bare) .env("GIT_TERMINAL_PROMPT", "0") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); let mut child = cmd.spawn().map_err(SshError::Io)?; let stderr_task = if let Some(stderr) = child.stderr.take() { Some(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 })) } else { None }; 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) } // Suppress unused import warnings while keeping the door open for future expansion. #[allow(dead_code)] fn _keep_module_in_scope() {}