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 { 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, 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) {}