| 1 | use std::process::Stdio; |
| 2 | use std::sync::Arc; |
| 3 | |
| 4 | use async_trait::async_trait; |
| 5 | use russh::keys::PublicKeyBase64; |
| 6 | use russh::server::{self, Auth, Msg, Session}; |
| 7 | use russh::{Channel, ChannelId, CryptoVec}; |
| 8 | use sqlx::SqlitePool; |
| 9 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 10 | use tokio::process::Command; |
| 11 | |
| 12 | use crate::auth; |
| 13 | use crate::config::Config; |
| 14 | use crate::repos; |
| 15 | use crate::validate; |
| 16 | |
| 17 | enum SignupState { |
| 18 | WaitingForUsername, |
| 19 | WaitingForInvite { username: String }, |
| 20 | } |
| 21 | |
| 22 | pub struct SshServer { |
| 23 | cfg: Arc<Config>, |
| 24 | pool: SqlitePool, |
| 25 | } |
| 26 | |
| 27 | impl SshServer { |
| 28 | pub fn new(cfg: Arc<Config>, pool: SqlitePool) -> Self { |
| 29 | Self { cfg, pool } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | impl server::Server for SshServer { |
| 34 | type Handler = SshHandler; |
| 35 | |
| 36 | fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> SshHandler { |
| 37 | SshHandler { |
| 38 | cfg: self.cfg.clone(), |
| 39 | pool: self.pool.clone(), |
| 40 | username: None, |
| 41 | user_id: None, |
| 42 | auth_key_openssh: None, |
| 43 | signup_state: None, |
| 44 | input_buf: String::new(), |
| 45 | child_stdin: None, |
| 46 | } |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | pub struct SshHandler { |
| 51 | cfg: Arc<Config>, |
| 52 | pool: SqlitePool, |
| 53 | username: Option<String>, |
| 54 | user_id: Option<i64>, |
| 55 | auth_key_openssh: Option<String>, |
| 56 | signup_state: Option<SignupState>, |
| 57 | input_buf: String, |
| 58 | child_stdin: Option<tokio::process::ChildStdin>, |
| 59 | } |
| 60 | |
| 61 | impl SshHandler { |
| 62 | async fn handle_openhub_command( |
| 63 | &mut self, |
| 64 | cmd: &str, |
| 65 | channel_id: ChannelId, |
| 66 | session: &mut Session, |
| 67 | ) -> bool { |
| 68 | let parts: Vec<&str> = cmd.trim().split_whitespace().collect(); |
| 69 | if parts.is_empty() || parts[0] != "repo" { |
| 70 | return false; |
| 71 | } |
| 72 | |
| 73 | let username = match &self.username { |
| 74 | Some(u) => u.clone(), |
| 75 | None => return false, |
| 76 | }; |
| 77 | let user_id = match self.user_id { |
| 78 | Some(id) => id, |
| 79 | None => return false, |
| 80 | }; |
| 81 | |
| 82 | match parts.get(1).copied() { |
| 83 | Some("new") => { |
| 84 | let name = match parts.get(2) { |
| 85 | Some(n) => *n, |
| 86 | None => { |
| 87 | session.data( |
| 88 | channel_id, |
| 89 | CryptoVec::from(b"Usage: repo new <name>\r\n".to_vec()), |
| 90 | ); |
| 91 | session.exit_status_request(channel_id, 1); |
| 92 | session.eof(channel_id); |
| 93 | session.close(channel_id); |
| 94 | return true; |
| 95 | } |
| 96 | }; |
| 97 | match repos::create_repo( |
| 98 | &self.pool, |
| 99 | &self.cfg.repos_root, |
| 100 | &self.cfg.hook_path, |
| 101 | self.cfg.max_repos_per_user, |
| 102 | user_id, |
| 103 | &username, |
| 104 | name, |
| 105 | ) |
| 106 | .await |
| 107 | { |
| 108 | Ok(()) => { |
| 109 | let msg = format!("Created {}/{}\r\n", username, name); |
| 110 | session.data(channel_id, CryptoVec::from(msg.into_bytes())); |
| 111 | session.exit_status_request(channel_id, 0); |
| 112 | } |
| 113 | Err(e) => { |
| 114 | let msg = match e.as_str() { |
| 115 | "invalid_repo_name" => "error: invalid repository name\r\n", |
| 116 | "repo_quota_exceeded" => "error: repository quota exceeded\r\n", |
| 117 | "repo_exists" => "error: repository already exists\r\n", |
| 118 | _ => "error: failed to create repository\r\n", |
| 119 | }; |
| 120 | session.data(channel_id, CryptoVec::from(msg.as_bytes().to_vec())); |
| 121 | session.exit_status_request(channel_id, 1); |
| 122 | } |
| 123 | } |
| 124 | session.eof(channel_id); |
| 125 | session.close(channel_id); |
| 126 | true |
| 127 | } |
| 128 | Some("list") => { |
| 129 | let target = parts.get(2).copied().unwrap_or(username.as_str()); |
| 130 | let rows = sqlx::query!( |
| 131 | r#"SELECT repos.name as "name!: String", users.username as "owner!: String" |
| 132 | FROM repos JOIN users ON users.id = repos.owner_id |
| 133 | WHERE users.username = ? |
| 134 | ORDER BY repos.name"#, |
| 135 | target |
| 136 | ) |
| 137 | .fetch_all(&self.pool) |
| 138 | .await; |
| 139 | |
| 140 | match rows { |
| 141 | Ok(repos) => { |
| 142 | if repos.is_empty() { |
| 143 | session.data( |
| 144 | channel_id, |
| 145 | CryptoVec::from(b"No repositories found.\r\n".to_vec()), |
| 146 | ); |
| 147 | } else { |
| 148 | for r in repos { |
| 149 | let line = format!("{}/{}\r\n", r.owner, r.name); |
| 150 | session.data(channel_id, CryptoVec::from(line.into_bytes())); |
| 151 | } |
| 152 | } |
| 153 | session.exit_status_request(channel_id, 0); |
| 154 | } |
| 155 | Err(_) => { |
| 156 | session.data( |
| 157 | channel_id, |
| 158 | CryptoVec::from(b"error: failed to list repos\r\n".to_vec()), |
| 159 | ); |
| 160 | session.exit_status_request(channel_id, 1); |
| 161 | } |
| 162 | } |
| 163 | session.eof(channel_id); |
| 164 | session.close(channel_id); |
| 165 | true |
| 166 | } |
| 167 | Some("delete") => { |
| 168 | let name = match parts.get(2) { |
| 169 | Some(n) => *n, |
| 170 | None => { |
| 171 | session.data( |
| 172 | channel_id, |
| 173 | CryptoVec::from(b"Usage: repo delete <name>\r\n".to_vec()), |
| 174 | ); |
| 175 | session.exit_status_request(channel_id, 1); |
| 176 | session.eof(channel_id); |
| 177 | session.close(channel_id); |
| 178 | return true; |
| 179 | } |
| 180 | }; |
| 181 | match repos::delete_repo(&self.pool, &self.cfg.repos_root, &username, name).await { |
| 182 | Ok(()) => { |
| 183 | let msg = format!("Deleted {}/{}\r\n", username, name); |
| 184 | session.data(channel_id, CryptoVec::from(msg.into_bytes())); |
| 185 | session.exit_status_request(channel_id, 0); |
| 186 | } |
| 187 | Err(e) => { |
| 188 | let msg = match e.as_str() { |
| 189 | "not_found" => "error: repository not found\r\n", |
| 190 | _ => "error: failed to delete repository\r\n", |
| 191 | }; |
| 192 | session.data(channel_id, CryptoVec::from(msg.as_bytes().to_vec())); |
| 193 | session.exit_status_request(channel_id, 1); |
| 194 | } |
| 195 | } |
| 196 | session.eof(channel_id); |
| 197 | session.close(channel_id); |
| 198 | true |
| 199 | } |
| 200 | _ => false, |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | async fn process_signup_input(&mut self, channel: ChannelId, session: &mut Session) { |
| 205 | let input = self.input_buf.trim().to_string(); |
| 206 | self.input_buf.clear(); |
| 207 | |
| 208 | let state = self.signup_state.take(); |
| 209 | match state { |
| 210 | Some(SignupState::WaitingForUsername) => { |
| 211 | if input.is_empty() { |
| 212 | session.data( |
| 213 | channel, |
| 214 | CryptoVec::from(b"Username cannot be empty.\r\nChoose a username: ".to_vec()), |
| 215 | ); |
| 216 | self.signup_state = Some(SignupState::WaitingForUsername); |
| 217 | return; |
| 218 | } |
| 219 | |
| 220 | if !validate::validate_username(&input) { |
| 221 | session.data( |
| 222 | channel, |
| 223 | CryptoVec::from( |
| 224 | b"Invalid username. Use 2-32 chars: lowercase letters, digits, hyphens, underscores.\r\nChoose a username: ".to_vec(), |
| 225 | ), |
| 226 | ); |
| 227 | self.signup_state = Some(SignupState::WaitingForUsername); |
| 228 | return; |
| 229 | } |
| 230 | |
| 231 | self.signup_state = Some(SignupState::WaitingForInvite { |
| 232 | username: input, |
| 233 | }); |
| 234 | session.data( |
| 235 | channel, |
| 236 | CryptoVec::from(b"Enter invite code: ".to_vec()), |
| 237 | ); |
| 238 | } |
| 239 | Some(SignupState::WaitingForInvite { username }) => { |
| 240 | if input.is_empty() { |
| 241 | session.data( |
| 242 | channel, |
| 243 | CryptoVec::from( |
| 244 | b"Invite code cannot be empty.\r\nEnter invite code: ".to_vec(), |
| 245 | ), |
| 246 | ); |
| 247 | self.signup_state = Some(SignupState::WaitingForInvite { username }); |
| 248 | return; |
| 249 | } |
| 250 | |
| 251 | let public_key = match &self.auth_key_openssh { |
| 252 | Some(k) => k.clone(), |
| 253 | None => { |
| 254 | session.data( |
| 255 | channel, |
| 256 | CryptoVec::from(b"Internal error.\r\n".to_vec()), |
| 257 | ); |
| 258 | session.exit_status_request(channel, 1); |
| 259 | session.eof(channel); |
| 260 | session.close(channel); |
| 261 | return; |
| 262 | } |
| 263 | }; |
| 264 | |
| 265 | match auth::create_user(&self.pool, &username, &public_key, &input).await { |
| 266 | Ok(user) => { |
| 267 | self.username = Some(user.username.clone()); |
| 268 | let msg = format!( |
| 269 | "\r\nAccount created! Welcome, {}.\r\nYou can now clone and push repos over SSH.\r\n", |
| 270 | user.username |
| 271 | ); |
| 272 | session.data(channel, CryptoVec::from(msg.into_bytes())); |
| 273 | session.exit_status_request(channel, 0); |
| 274 | session.eof(channel); |
| 275 | session.close(channel); |
| 276 | } |
| 277 | Err(e) => match e.as_str() { |
| 278 | "username_taken" => { |
| 279 | session.data( |
| 280 | channel, |
| 281 | CryptoVec::from( |
| 282 | b"That username is taken.\r\nChoose a username: ".to_vec(), |
| 283 | ), |
| 284 | ); |
| 285 | self.signup_state = Some(SignupState::WaitingForUsername); |
| 286 | } |
| 287 | "invalid_invite" => { |
| 288 | session.data( |
| 289 | channel, |
| 290 | CryptoVec::from( |
| 291 | b"Invalid invite code.\r\nEnter invite code: ".to_vec(), |
| 292 | ), |
| 293 | ); |
| 294 | self.signup_state = Some(SignupState::WaitingForInvite { username }); |
| 295 | } |
| 296 | "invite_used" => { |
| 297 | session.data( |
| 298 | channel, |
| 299 | CryptoVec::from( |
| 300 | b"That invite has already been used.\r\nEnter invite code: " |
| 301 | .to_vec(), |
| 302 | ), |
| 303 | ); |
| 304 | self.signup_state = Some(SignupState::WaitingForInvite { username }); |
| 305 | } |
| 306 | _ => { |
| 307 | session.data( |
| 308 | channel, |
| 309 | CryptoVec::from(b"Something went wrong. Try again later.\r\n".to_vec()), |
| 310 | ); |
| 311 | session.exit_status_request(channel, 1); |
| 312 | session.eof(channel); |
| 313 | session.close(channel); |
| 314 | } |
| 315 | }, |
| 316 | } |
| 317 | } |
| 318 | None => {} |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | #[async_trait] |
| 324 | impl server::Handler for SshHandler { |
| 325 | type Error = russh::Error; |
| 326 | |
| 327 | async fn auth_publickey( |
| 328 | &mut self, |
| 329 | _user: &str, |
| 330 | key: &russh::keys::key::PublicKey, |
| 331 | ) -> Result<Auth, Self::Error> { |
| 332 | // Only accept ed25519 keys — reject others so SSH client tries next key |
| 333 | if key.name() != "ssh-ed25519" { |
| 334 | return Ok(Auth::Reject { proceed_with_methods: None }); |
| 335 | } |
| 336 | |
| 337 | // Store key in OpenSSH format for potential account creation |
| 338 | self.auth_key_openssh = Some(format!("{} {}", key.name(), key.public_key_base64())); |
| 339 | |
| 340 | // Check if this key belongs to a registered user |
| 341 | if let Some((id, username)) = auth::lookup_user_by_pubkey(&self.pool, key).await { |
| 342 | self.user_id = Some(id); |
| 343 | self.username = Some(username); |
| 344 | } |
| 345 | |
| 346 | // Accept ed25519 keys — unregistered ones get interactive signup |
| 347 | Ok(Auth::Accept) |
| 348 | } |
| 349 | |
| 350 | async fn channel_open_session( |
| 351 | &mut self, |
| 352 | _channel: Channel<Msg>, |
| 353 | _session: &mut Session, |
| 354 | ) -> Result<bool, Self::Error> { |
| 355 | Ok(true) |
| 356 | } |
| 357 | |
| 358 | async fn shell_request( |
| 359 | &mut self, |
| 360 | channel_id: ChannelId, |
| 361 | session: &mut Session, |
| 362 | ) -> Result<(), Self::Error> { |
| 363 | if let Some(ref username) = self.username { |
| 364 | let msg = format!( |
| 365 | "Hi {}! You've authenticated, but openhub doesn't provide shell access.\r\n", |
| 366 | username |
| 367 | ); |
| 368 | session.data(channel_id, CryptoVec::from(msg.into_bytes())); |
| 369 | session.exit_status_request(channel_id, 0); |
| 370 | session.eof(channel_id); |
| 371 | session.close(channel_id); |
| 372 | } else { |
| 373 | // Unregistered key — start interactive signup |
| 374 | self.signup_state = Some(SignupState::WaitingForUsername); |
| 375 | let welcome = |
| 376 | "\r\nWelcome to openhub! Your SSH key isn't registered yet.\r\n\r\nChoose a username: "; |
| 377 | session.data( |
| 378 | channel_id, |
| 379 | CryptoVec::from(welcome.as_bytes().to_vec()), |
| 380 | ); |
| 381 | } |
| 382 | Ok(()) |
| 383 | } |
| 384 | |
| 385 | async fn exec_request( |
| 386 | &mut self, |
| 387 | channel_id: ChannelId, |
| 388 | data: &[u8], |
| 389 | session: &mut Session, |
| 390 | ) -> Result<(), Self::Error> { |
| 391 | let username = match &self.username { |
| 392 | Some(u) => u.clone(), |
| 393 | None => { |
| 394 | session.data( |
| 395 | channel_id, |
| 396 | CryptoVec::from( |
| 397 | b"Your SSH key isn't registered. Run 'ssh git@<host>' to create an account.\r\n" |
| 398 | .to_vec(), |
| 399 | ), |
| 400 | ); |
| 401 | session.exit_status_request(channel_id, 1); |
| 402 | session.eof(channel_id); |
| 403 | session.close(channel_id); |
| 404 | return Ok(()); |
| 405 | } |
| 406 | }; |
| 407 | |
| 408 | let cmd_str = String::from_utf8_lossy(data); |
| 409 | let (git_cmd, repo_path) = match parse_git_command(&cmd_str) { |
| 410 | Some(parsed) => parsed, |
| 411 | None => { |
| 412 | if self |
| 413 | .handle_openhub_command(&cmd_str, channel_id, session) |
| 414 | .await |
| 415 | { |
| 416 | return Ok(()); |
| 417 | } |
| 418 | session.data(channel_id, CryptoVec::from(b"Invalid command.\r\n".to_vec())); |
| 419 | session.close(channel_id); |
| 420 | return Ok(()); |
| 421 | } |
| 422 | }; |
| 423 | |
| 424 | // Parse owner/repo from path like "/user/repo.git" or "'user/repo.git'" |
| 425 | let path = repo_path.trim_matches(|c| c == '\'' || c == '"'); |
| 426 | let path = path.trim_start_matches('/'); |
| 427 | let mut parts = path.splitn(2, '/'); |
| 428 | let owner = match parts.next() { |
| 429 | Some(o) if !o.is_empty() => o.to_string(), |
| 430 | _ => { |
| 431 | session.data(channel_id, CryptoVec::from(b"Invalid repository path.\r\n".to_vec())); |
| 432 | session.close(channel_id); |
| 433 | return Ok(()); |
| 434 | } |
| 435 | }; |
| 436 | let repo_git = match parts.next() { |
| 437 | Some(r) if !r.is_empty() => r.to_string(), |
| 438 | _ => { |
| 439 | session.data(channel_id, CryptoVec::from(b"Invalid repository path.\r\n".to_vec())); |
| 440 | session.close(channel_id); |
| 441 | return Ok(()); |
| 442 | } |
| 443 | }; |
| 444 | |
| 445 | let repo_name = repo_git.trim_end_matches(".git"); |
| 446 | |
| 447 | // Verify repo exists |
| 448 | if !repos::repo_exists(&self.pool, &owner, repo_name).await { |
| 449 | let msg = format!("Repository {}/{} not found.\r\n", owner, repo_name); |
| 450 | session.data(channel_id, CryptoVec::from(msg.into_bytes())); |
| 451 | session.close(channel_id); |
| 452 | return Ok(()); |
| 453 | } |
| 454 | |
| 455 | // For push (git-receive-pack), verify the user owns the repo |
| 456 | if git_cmd == "git-receive-pack" && username != owner { |
| 457 | session.data(channel_id, CryptoVec::from(b"Permission denied.\r\n".to_vec())); |
| 458 | session.close(channel_id); |
| 459 | return Ok(()); |
| 460 | } |
| 461 | |
| 462 | // Resolve full repo path on disk |
| 463 | let full_path = format!("{}/{}/{}.git", self.cfg.repos_root, owner, repo_name); |
| 464 | |
| 465 | // Spawn git subprocess |
| 466 | let mut child = match Command::new(&git_cmd) |
| 467 | .arg(&full_path) |
| 468 | .env("MAX_REPO_BYTES", self.cfg.max_repo_bytes.to_string()) |
| 469 | .stdin(Stdio::piped()) |
| 470 | .stdout(Stdio::piped()) |
| 471 | .stderr(Stdio::piped()) |
| 472 | .spawn() |
| 473 | { |
| 474 | Ok(c) => c, |
| 475 | Err(e) => { |
| 476 | let msg = format!("Failed to start {}: {}\r\n", git_cmd, e); |
| 477 | session.data(channel_id, CryptoVec::from(msg.into_bytes())); |
| 478 | session.close(channel_id); |
| 479 | return Ok(()); |
| 480 | } |
| 481 | }; |
| 482 | |
| 483 | let stdout = child.stdout.take().unwrap(); |
| 484 | let stderr = child.stderr.take().unwrap(); |
| 485 | self.child_stdin = child.stdin.take(); |
| 486 | |
| 487 | let handle = session.handle(); |
| 488 | |
| 489 | // Pipe stdout to channel data |
| 490 | let handle_out = handle.clone(); |
| 491 | let stdout_task = tokio::spawn(async move { |
| 492 | let mut stdout = stdout; |
| 493 | let mut buf = vec![0u8; 32768]; |
| 494 | loop { |
| 495 | match stdout.read(&mut buf).await { |
| 496 | Ok(0) => break, |
| 497 | Ok(n) => { |
| 498 | let data = CryptoVec::from(buf[..n].to_vec()); |
| 499 | if handle_out.data(channel_id, data).await.is_err() { |
| 500 | break; |
| 501 | } |
| 502 | } |
| 503 | Err(_) => break, |
| 504 | } |
| 505 | } |
| 506 | }); |
| 507 | |
| 508 | // Pipe stderr to channel extended data (type 1 = stderr) |
| 509 | let handle_err = handle.clone(); |
| 510 | let stderr_task = tokio::spawn(async move { |
| 511 | let mut stderr = stderr; |
| 512 | let mut buf = vec![0u8; 32768]; |
| 513 | loop { |
| 514 | match stderr.read(&mut buf).await { |
| 515 | Ok(0) => break, |
| 516 | Ok(n) => { |
| 517 | let data = CryptoVec::from(buf[..n].to_vec()); |
| 518 | if handle_err.extended_data(channel_id, 1, data).await.is_err() { |
| 519 | break; |
| 520 | } |
| 521 | } |
| 522 | Err(_) => break, |
| 523 | } |
| 524 | } |
| 525 | }); |
| 526 | |
| 527 | // Wait for process exit, then send exit status and close |
| 528 | tokio::spawn(async move { |
| 529 | let status = child.wait().await; |
| 530 | // Wait for stdout/stderr to be fully piped before closing |
| 531 | let _ = stdout_task.await; |
| 532 | let _ = stderr_task.await; |
| 533 | |
| 534 | let code = status |
| 535 | .map(|s| s.code().unwrap_or(1) as u32) |
| 536 | .unwrap_or(1); |
| 537 | let _ = handle.exit_status_request(channel_id, code).await; |
| 538 | let _ = handle.eof(channel_id).await; |
| 539 | let _ = handle.close(channel_id).await; |
| 540 | }); |
| 541 | |
| 542 | Ok(()) |
| 543 | } |
| 544 | |
| 545 | async fn data( |
| 546 | &mut self, |
| 547 | channel: ChannelId, |
| 548 | data: &[u8], |
| 549 | session: &mut Session, |
| 550 | ) -> Result<(), Self::Error> { |
| 551 | // Interactive signup mode — handle terminal input |
| 552 | if self.signup_state.is_some() { |
| 553 | for &byte in data { |
| 554 | match byte { |
| 555 | // Backspace / Delete |
| 556 | 0x7f | 0x08 => { |
| 557 | if !self.input_buf.is_empty() { |
| 558 | self.input_buf.pop(); |
| 559 | session.data(channel, CryptoVec::from(b"\x08 \x08".to_vec())); |
| 560 | } |
| 561 | } |
| 562 | // Ctrl+C / Ctrl+D — abort |
| 563 | 0x03 | 0x04 => { |
| 564 | session.data(channel, CryptoVec::from(b"\r\nAborted.\r\n".to_vec())); |
| 565 | session.exit_status_request(channel, 1); |
| 566 | session.eof(channel); |
| 567 | session.close(channel); |
| 568 | return Ok(()); |
| 569 | } |
| 570 | // Enter |
| 571 | b'\r' | b'\n' => { |
| 572 | session.data(channel, CryptoVec::from(b"\r\n".to_vec())); |
| 573 | self.process_signup_input(channel, session).await; |
| 574 | } |
| 575 | // Printable ASCII |
| 576 | byte if byte >= 0x20 && byte < 0x7f => { |
| 577 | self.input_buf.push(byte as char); |
| 578 | session.data(channel, CryptoVec::from(vec![byte])); |
| 579 | } |
| 580 | _ => {} |
| 581 | } |
| 582 | } |
| 583 | return Ok(()); |
| 584 | } |
| 585 | |
| 586 | // Normal mode: forward to subprocess stdin |
| 587 | if let Some(ref mut stdin) = self.child_stdin { |
| 588 | let _ = stdin.write_all(data).await; |
| 589 | } |
| 590 | Ok(()) |
| 591 | } |
| 592 | |
| 593 | async fn channel_eof( |
| 594 | &mut self, |
| 595 | _channel: ChannelId, |
| 596 | _session: &mut Session, |
| 597 | ) -> Result<(), Self::Error> { |
| 598 | // Drop stdin to signal EOF to the subprocess |
| 599 | self.child_stdin.take(); |
| 600 | Ok(()) |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | fn parse_git_command(cmd: &str) -> Option<(String, String)> { |
| 605 | let cmd = cmd.trim(); |
| 606 | for prefix in &["git-upload-pack", "git-receive-pack"] { |
| 607 | if cmd.starts_with(prefix) { |
| 608 | let rest = cmd[prefix.len()..].trim(); |
| 609 | if !rest.is_empty() { |
| 610 | return Some((prefix.to_string(), rest.to_string())); |
| 611 | } |
| 612 | } |
| 613 | } |
| 614 | None |
| 615 | } |