use bytes::Bytes; use russh::ChannelId; use russh::server::Session; 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(error) => { tracing::warn!(%error, "space lookup failed, falling back to dir id"); 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, self.service .smart .git .state .config .git .git_rpc_timeout_secs, ) .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 { crate::share::access::space_for_dir(self.service.smart.git.db_read(), *dir_id) .await .map_err(SmartError::from)? .map(|s| s.id) .ok_or_else(|| 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, timeout_secs: u64, ) -> Result, 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 = match child.stderr.take() { Some(stderr) => Some(tokio::spawn(async move { use tokio::io::AsyncReadExt; let s = stderr; let mut buf = Vec::new(); let _ = s.take((64 * 1024 * 1024) as u64 + 1) .read_to_end(&mut buf) .await; buf })), None => None, }; let stdout = child .stdout .take() .ok_or_else(|| SshError::Internal("git stdout missing".to_string()))?; let mut out = Vec::new(); use tokio::io::AsyncReadExt; stdout .take((64 * 1024 * 1024) as u64 + 1) .read_to_end(&mut out) .await .map_err(SshError::Io)?; let status = match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), child.wait()) .await { Ok(Ok(s)) => s, _ => { let _ = child.start_kill(); let _ = child.wait().await; return Err(SshError::Internal(format!( "git {} --advertise-refs timed out", service.as_subcommand() ))); } }; let stderr_bytes = match stderr_task { Some(t) => t.await.map_err(|e| { SshError::Internal(format!("stderr task join failed: {e}")) })?, 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) {}