4255bcdb
Handle release upload, list and delete over SSH
a73x 2026-08-08 15:08
Commit message
docs/superpowers/plans/2026-08-08-release-packages.md
| Old | New | ||
|---|---|---|---|
| @@ -691,6 +691,10 @@ git commit -m "Add max_release_size server config option" | |||
| 691 | 691 | ||
| 692 | ### Task 4: Parse `collab-release` exec commands | 692 | ### Task 4: Parse `collab-release` exec commands |
| 693 | 693 | ||
| 694 | **Execution deviations:** | ||
| 695 | - `parse_exec_command`: added an explicit empty-token check (`verb.is_empty() || rest.iter().any(|a| a.is_empty())`) after collecting `rest`, so `collab-release list ''` etc. are rejected instead of producing a `ReleaseCmd` with an empty repo/version/filename. | ||
| 696 | - `shell_tokens`: switched the whitespace test from `c.is_whitespace()` to `c == ' ' || c == '\t'`, so only ASCII space/tab split tokens (tighter grammar on an allowlist gate; no real ssh client sends Unicode separators here). | ||
| 697 | |||
| 694 | **Files:** | 698 | **Files:** |
| 695 | - Modify: `src/server/ssh/session.rs` (new enum + parser + tests; keep `parse_git_command` as-is) | 699 | - Modify: `src/server/ssh/session.rs` (new enum + parser + tests; keep `parse_git_command` as-is) |
| 696 | 700 | ||
| @@ -913,7 +917,7 @@ pub fn parse_exec_command(data: &str) -> Option<ExecCommand> { | |||
| 913 | - [ ] **Step 4: Run tests to verify they pass** | 917 | - [ ] **Step 4: Run tests to verify they pass** |
| 914 | 918 | ||
| 915 | Run: `cargo test --bin git-collab-server session::` | 919 | Run: `cargo test --bin git-collab-server session::` |
| 916 | Expected: PASS (all old + 6 new tests) | 920 | Expected: PASS (all old + 5 new tests) |
| 917 | 921 | ||
| 918 | - [ ] **Step 5: Commit** | 922 | - [ ] **Step 5: Commit** |
| 919 | 923 | ||
| @@ -1587,6 +1591,14 @@ git add src/server/ssh/session.rs tests/release_server_test.rs | |||
| 1587 | git commit -m "Handle collab-release upload/list/delete over SSH" | 1591 | git commit -m "Handle collab-release upload/list/delete over SSH" |
| 1588 | ``` | 1592 | ``` |
| 1589 | 1593 | ||
| 1594 | **Execution deviations (Task 6):** | ||
| 1595 | |||
| 1596 | - Amendment A applied: `ExecCommand::Git { cmd: GitCmd, repo }` with `enum GitCmd { UploadPack, ReceivePack }` + `as_str()`; `ensure_repo_exists_for_command` and `run_git_command` now take `GitCmd` by value (it is `Copy`), and the authorization match is on the enum, not on strings. | ||
| 1597 | - Amendment B applied: `ExecCommand::repo(&self) -> &str` is used for the `resolve_repo_path` call instead of re-matching. | ||
| 1598 | - Release dispatch is an early return (`let git_cmd = match exec_cmd { Git { cmd, .. } => cmd, Release(rel) => { self.handle_release_command(..); return Ok(()); } };`) rather than wrapping the whole git flow in a `match` arm — behaviorally identical, avoids re-indenting the untouched git flow. | ||
| 1599 | - `UploadState::Active` holds a `Box<ReleaseUpload>` to satisfy clippy's `large_enum_variant`. | ||
| 1600 | - `src/server/ssh/session.rs` and `tests/release_server_test.rs` were run through `rustfmt` (both had pre-existing drift; `src/server/releases.rs` and `tests/common/mod.rs` still do and were left alone). | ||
| 1601 | |||
| 1590 | --- | 1602 | --- |
| 1591 | 1603 | ||
| 1592 | ### Task 7: HTTP releases page and downloads | 1604 | ### Task 7: HTTP releases page and downloads |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -26,6 +26,20 @@ pub struct SshHandler { | |||
| 26 | authenticated_principal: Option<String>, | 26 | authenticated_principal: Option<String>, |
| 27 | /// Sender for forwarding client data (stdin) to the spawned git subprocess. | 27 | /// Sender for forwarding client data (stdin) to the spawned git subprocess. |
| 28 | stdin_tx: Option<mpsc::Sender<Vec<u8>>>, | 28 | stdin_tx: Option<mpsc::Sender<Vec<u8>>>, |
| 29 | /// In-progress release upload, fed by data() and finalized on channel EOF. | ||
| 30 | active_upload: Option<UploadSession>, | ||
| 31 | } | ||
| 32 | |||
| 33 | struct UploadSession { | ||
| 34 | channel: ChannelId, | ||
| 35 | state: UploadState, | ||
| 36 | } | ||
| 37 | |||
| 38 | enum UploadState { | ||
| 39 | // Boxed: ReleaseUpload is an order of magnitude larger than the error | ||
| 40 | // variant, and this enum lives in every handler for the connection's life. | ||
| 41 | Active(Box<crate::releases::ReleaseUpload>), | ||
| 42 | Failed(String), | ||
| 29 | } | 43 | } |
| 30 | 44 | ||
| 31 | impl SshHandler { | 45 | impl SshHandler { |
| @@ -34,6 +48,88 @@ impl SshHandler { | |||
| 34 | config, | 48 | config, |
| 35 | authenticated_principal: None, | 49 | authenticated_principal: None, |
| 36 | stdin_tx: None, | 50 | stdin_tx: None, |
| 51 | active_upload: None, | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | fn handle_release_command( | ||
| 56 | &mut self, | ||
| 57 | channel: ChannelId, | ||
| 58 | session: &mut Session, | ||
| 59 | rel: ReleaseCmd, | ||
| 60 | resolved_path: &Path, | ||
| 61 | principal: &str, | ||
| 62 | ) { | ||
| 63 | // Releases never auto-create a repo (unlike git-receive-pack). | ||
| 64 | let entry = match crate::repos::entry_for_path(resolved_path) { | ||
| 65 | Some(entry) => entry, | ||
| 66 | None => { | ||
| 67 | warn!("Rejected release command: unknown repo {:?}", resolved_path); | ||
| 68 | reply_and_close(session, channel, "error: repository not found\n", 1); | ||
| 69 | return; | ||
| 70 | } | ||
| 71 | }; | ||
| 72 | |||
| 73 | let authorized = match &rel { | ||
| 74 | ReleaseCmd::List { .. } => entry.policy.allows_read(principal), | ||
| 75 | ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => { | ||
| 76 | entry.policy.allows_write(principal) | ||
| 77 | } | ||
| 78 | }; | ||
| 79 | if !authorized { | ||
| 80 | warn!( | ||
| 81 | "Rejected release command: principal {} not authorized on {:?}", | ||
| 82 | principal, resolved_path | ||
| 83 | ); | ||
| 84 | reply_and_close(session, channel, "error: not authorized\n", 1); | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | |||
| 88 | let dir = crate::releases::releases_dir(&entry); | ||
| 89 | match rel { | ||
| 90 | ReleaseCmd::Upload { | ||
| 91 | version, | ||
| 92 | filename, | ||
| 93 | force, | ||
| 94 | .. | ||
| 95 | } => match crate::releases::ReleaseUpload::begin( | ||
| 96 | &dir, | ||
| 97 | &version, | ||
| 98 | &filename, | ||
| 99 | force, | ||
| 100 | self.config.max_release_size, | ||
| 101 | ) { | ||
| 102 | Ok(upload) => { | ||
| 103 | self.active_upload = Some(UploadSession { | ||
| 104 | channel, | ||
| 105 | state: UploadState::Active(Box::new(upload)), | ||
| 106 | }); | ||
| 107 | // Reply comes on channel EOF, once all bytes have arrived. | ||
| 108 | } | ||
| 109 | Err(e) => { | ||
| 110 | reply_and_close(session, channel, &format!("error: {}\n", e), 1); | ||
| 111 | } | ||
| 112 | }, | ||
| 113 | ReleaseCmd::List { .. } => match crate::releases::list_releases(&dir) { | ||
| 114 | Ok(index) => { | ||
| 115 | let json = serde_json::to_string_pretty(&index) | ||
| 116 | .unwrap_or_else(|_| "{\"versions\":[]}".to_string()); | ||
| 117 | reply_and_close(session, channel, &format!("{}\n", json), 0); | ||
| 118 | } | ||
| 119 | Err(e) => { | ||
| 120 | reply_and_close(session, channel, &format!("error: {}\n", e), 1); | ||
| 121 | } | ||
| 122 | }, | ||
| 123 | ReleaseCmd::Delete { | ||
| 124 | version, filename, .. | ||
| 125 | } => match crate::releases::delete_release(&dir, &version, filename.as_deref()) { | ||
| 126 | Ok(()) => { | ||
| 127 | reply_and_close(session, channel, "deleted\n", 0); | ||
| 128 | } | ||
| 129 | Err(e) => { | ||
| 130 | reply_and_close(session, channel, &format!("error: {}\n", e), 1); | ||
| 131 | } | ||
| 132 | }, | ||
| 37 | } | 133 | } |
| 38 | } | 134 | } |
| 39 | } | 135 | } |
| @@ -66,6 +162,163 @@ pub fn parse_git_command(data: &str) -> Option<(&str, &str)> { | |||
| 66 | Some((cmd, path)) | 162 | Some((cmd, path)) |
| 67 | } | 163 | } |
| 68 | 164 | ||
| 165 | /// The only two git binaries this server will ever spawn. Keeping this an | ||
| 166 | /// enum (rather than a String lifted from the client's exec request) makes | ||
| 167 | /// "spawn an attacker-named binary" unrepresentable. | ||
| 168 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 169 | pub enum GitCmd { | ||
| 170 | UploadPack, | ||
| 171 | ReceivePack, | ||
| 172 | } | ||
| 173 | |||
| 174 | impl GitCmd { | ||
| 175 | pub fn as_str(&self) -> &'static str { | ||
| 176 | match self { | ||
| 177 | GitCmd::UploadPack => "git-upload-pack", | ||
| 178 | GitCmd::ReceivePack => "git-receive-pack", | ||
| 179 | } | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 184 | pub enum ExecCommand { | ||
| 185 | Git { cmd: GitCmd, repo: String }, | ||
| 186 | Release(ReleaseCmd), | ||
| 187 | } | ||
| 188 | |||
| 189 | impl ExecCommand { | ||
| 190 | /// The repo argument, whichever command shape this is. | ||
| 191 | pub fn repo(&self) -> &str { | ||
| 192 | match self { | ||
| 193 | ExecCommand::Git { repo, .. } => repo, | ||
| 194 | ExecCommand::Release(rel) => rel.repo(), | ||
| 195 | } | ||
| 196 | } | ||
| 197 | } | ||
| 198 | |||
| 199 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 200 | pub enum ReleaseCmd { | ||
| 201 | Upload { | ||
| 202 | repo: String, | ||
| 203 | version: String, | ||
| 204 | filename: String, | ||
| 205 | force: bool, | ||
| 206 | }, | ||
| 207 | List { | ||
| 208 | repo: String, | ||
| 209 | }, | ||
| 210 | Delete { | ||
| 211 | repo: String, | ||
| 212 | version: String, | ||
| 213 | filename: Option<String>, | ||
| 214 | }, | ||
| 215 | } | ||
| 216 | |||
| 217 | impl ReleaseCmd { | ||
| 218 | pub fn repo(&self) -> &str { | ||
| 219 | match self { | ||
| 220 | ReleaseCmd::Upload { repo, .. } | ||
| 221 | | ReleaseCmd::List { repo } | ||
| 222 | | ReleaseCmd::Delete { repo, .. } => repo, | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | /// Split an exec string into tokens, honoring single/double quotes. | ||
| 228 | /// Returns None on unbalanced quotes. No escape sequences (release names | ||
| 229 | /// have a restricted charset; git paths never need them here either). | ||
| 230 | fn shell_tokens(input: &str) -> Option<Vec<String>> { | ||
| 231 | let mut tokens = Vec::new(); | ||
| 232 | let mut current = String::new(); | ||
| 233 | let mut in_token = false; | ||
| 234 | let mut quote: Option<char> = None; | ||
| 235 | |||
| 236 | for c in input.trim().chars() { | ||
| 237 | match quote { | ||
| 238 | Some(q) if c == q => quote = None, | ||
| 239 | Some(_) => current.push(c), | ||
| 240 | None if c == '\'' || c == '"' => { | ||
| 241 | quote = Some(c); | ||
| 242 | in_token = true; | ||
| 243 | } | ||
| 244 | None if c == ' ' || c == '\t' => { | ||
| 245 | if in_token { | ||
| 246 | tokens.push(std::mem::take(&mut current)); | ||
| 247 | in_token = false; | ||
| 248 | } | ||
| 249 | } | ||
| 250 | None => { | ||
| 251 | current.push(c); | ||
| 252 | in_token = true; | ||
| 253 | } | ||
| 254 | } | ||
| 255 | } | ||
| 256 | if quote.is_some() { | ||
| 257 | return None; | ||
| 258 | } | ||
| 259 | if in_token { | ||
| 260 | tokens.push(current); | ||
| 261 | } | ||
| 262 | Some(tokens) | ||
| 263 | } | ||
| 264 | |||
| 265 | /// Parse an SSH exec request into an allowed command, or None if rejected. | ||
| 266 | pub fn parse_exec_command(data: &str) -> Option<ExecCommand> { | ||
| 267 | if let Some((cmd, repo)) = parse_git_command(data) { | ||
| 268 | let cmd = match cmd { | ||
| 269 | "git-upload-pack" => GitCmd::UploadPack, | ||
| 270 | "git-receive-pack" => GitCmd::ReceivePack, | ||
| 271 | // parse_git_command only accepts the two above. | ||
| 272 | _ => return None, | ||
| 273 | }; | ||
| 274 | return Some(ExecCommand::Git { | ||
| 275 | cmd, | ||
| 276 | repo: repo.to_string(), | ||
| 277 | }); | ||
| 278 | } | ||
| 279 | |||
| 280 | let tokens = shell_tokens(data)?; | ||
| 281 | let mut it = tokens.into_iter(); | ||
| 282 | if it.next()? != "collab-release" { | ||
| 283 | return None; | ||
| 284 | } | ||
| 285 | let verb = it.next()?; | ||
| 286 | let rest: Vec<String> = it.collect(); | ||
| 287 | if verb.is_empty() || rest.iter().any(|a| a.is_empty()) { | ||
| 288 | return None; | ||
| 289 | } | ||
| 290 | match (verb.as_str(), rest.as_slice()) { | ||
| 291 | ("upload", [repo, version, filename]) => Some(ExecCommand::Release(ReleaseCmd::Upload { | ||
| 292 | repo: repo.clone(), | ||
| 293 | version: version.clone(), | ||
| 294 | filename: filename.clone(), | ||
| 295 | force: false, | ||
| 296 | })), | ||
| 297 | ("upload", [repo, version, filename, flag]) if flag == "--force" => { | ||
| 298 | Some(ExecCommand::Release(ReleaseCmd::Upload { | ||
| 299 | repo: repo.clone(), | ||
| 300 | version: version.clone(), | ||
| 301 | filename: filename.clone(), | ||
| 302 | force: true, | ||
| 303 | })) | ||
| 304 | } | ||
| 305 | ("list", [repo]) => Some(ExecCommand::Release(ReleaseCmd::List { | ||
| 306 | repo: repo.clone(), | ||
| 307 | })), | ||
| 308 | ("delete", [repo, version]) => Some(ExecCommand::Release(ReleaseCmd::Delete { | ||
| 309 | repo: repo.clone(), | ||
| 310 | version: version.clone(), | ||
| 311 | filename: None, | ||
| 312 | })), | ||
| 313 | ("delete", [repo, version, filename]) => Some(ExecCommand::Release(ReleaseCmd::Delete { | ||
| 314 | repo: repo.clone(), | ||
| 315 | version: version.clone(), | ||
| 316 | filename: Some(filename.clone()), | ||
| 317 | })), | ||
| 318 | _ => None, | ||
| 319 | } | ||
| 320 | } | ||
| 321 | |||
| 69 | /// Resolve a requested repo path to a safe absolute path under repos_dir. | 322 | /// Resolve a requested repo path to a safe absolute path under repos_dir. |
| 70 | /// Returns None if the path escapes repos_dir (e.g. via `..` or symlinks). | 323 | /// Returns None if the path escapes repos_dir (e.g. via `..` or symlinks). |
| 71 | pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { | 324 | pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { |
| @@ -97,12 +350,12 @@ pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { | |||
| 97 | Some(full) | 350 | Some(full) |
| 98 | } | 351 | } |
| 99 | 352 | ||
| 100 | fn ensure_repo_exists_for_command(git_cmd: &str, repo_path: &Path) -> Result<bool, git2::Error> { | 353 | fn ensure_repo_exists_for_command(git_cmd: GitCmd, repo_path: &Path) -> Result<bool, git2::Error> { |
| 101 | if repo_path.exists() { | 354 | if repo_path.exists() { |
| 102 | return Ok(false); | 355 | return Ok(false); |
| 103 | } | 356 | } |
| 104 | 357 | ||
| 105 | if git_cmd != "git-receive-pack" { | 358 | if git_cmd != GitCmd::ReceivePack { |
| 106 | return Ok(false); | 359 | return Ok(false); |
| 107 | } | 360 | } |
| 108 | 361 | ||
| @@ -186,36 +439,37 @@ impl Handler for SshHandler { | |||
| 186 | 439 | ||
| 187 | info!("Exec request: {}", command_str); | 440 | info!("Exec request: {}", command_str); |
| 188 | 441 | ||
| 189 | let (git_cmd, repo_path) = | 442 | let exec_cmd = match parse_exec_command(command_str) { |
| 190 | match parse_git_command(command_str).map(|(c, p)| (c.to_owned(), p.to_owned())) { | 443 | Some(c) => c, |
| 191 | Some((c, p)) => (c, p), | 444 | None => { |
| 192 | None => { | 445 | warn!("Rejected exec request: not an allowed command"); |
| 193 | warn!("Rejected exec request: not a valid git command"); | 446 | reply_and_close(session, channel, "", 1); |
| 194 | session.exit_status_request(channel, 1); | 447 | return Ok(()); |
| 195 | session.eof(channel); | 448 | } |
| 196 | session.close(channel); | 449 | }; |
| 197 | return Ok(()); | ||
| 198 | } | ||
| 199 | }; | ||
| 200 | 450 | ||
| 201 | let principal = match self.authenticated_principal.clone() { | 451 | let principal = match self.authenticated_principal.clone() { |
| 202 | Some(principal) => principal, | 452 | Some(principal) => principal, |
| 203 | None => { | 453 | None => { |
| 204 | warn!("Rejected exec request: not authenticated"); | 454 | warn!("Rejected exec request: not authenticated"); |
| 205 | session.exit_status_request(channel, 1); | 455 | reply_and_close(session, channel, "", 1); |
| 206 | session.eof(channel); | ||
| 207 | session.close(channel); | ||
| 208 | return Ok(()); | 456 | return Ok(()); |
| 209 | } | 457 | } |
| 210 | }; | 458 | }; |
| 211 | 459 | ||
| 212 | let resolved_path = match resolve_repo_path(&self.config.repos_dir, &repo_path) { | 460 | let resolved_path = match resolve_repo_path(&self.config.repos_dir, exec_cmd.repo()) { |
| 213 | Some(p) => p, | 461 | Some(p) => p, |
| 214 | None => { | 462 | None => { |
| 215 | warn!("Rejected exec request: path traversal detected"); | 463 | warn!("Rejected exec request: path traversal detected"); |
| 216 | session.exit_status_request(channel, 1); | 464 | reply_and_close(session, channel, "error: invalid repo path\n", 1); |
| 217 | session.eof(channel); | 465 | return Ok(()); |
| 218 | session.close(channel); | 466 | } |
| 467 | }; | ||
| 468 | |||
| 469 | let git_cmd = match exec_cmd { | ||
| 470 | ExecCommand::Git { cmd, .. } => cmd, | ||
| 471 | ExecCommand::Release(rel) => { | ||
| 472 | self.handle_release_command(channel, session, rel, &resolved_path, &principal); | ||
| 219 | return Ok(()); | 473 | return Ok(()); |
| 220 | } | 474 | } |
| 221 | }; | 475 | }; |
| @@ -235,16 +489,17 @@ impl Handler for SshHandler { | |||
| 235 | } | 489 | } |
| 236 | }; | 490 | }; |
| 237 | 491 | ||
| 238 | let authorized = match git_cmd.as_str() { | 492 | let authorized = match git_cmd { |
| 239 | "git-upload-pack" => entry.policy.allows_read(&principal), | 493 | GitCmd::UploadPack => entry.policy.allows_read(&principal), |
| 240 | "git-receive-pack" => entry.policy.allows_write(&principal), | 494 | GitCmd::ReceivePack => entry.policy.allows_write(&principal), |
| 241 | _ => false, | ||
| 242 | }; | 495 | }; |
| 243 | 496 | ||
| 244 | if !authorized { | 497 | if !authorized { |
| 245 | warn!( | 498 | warn!( |
| 246 | "Rejected exec request: principal {} is not authorized for {} on {:?}", | 499 | "Rejected exec request: principal {} is not authorized for {} on {:?}", |
| 247 | principal, git_cmd, resolved_path | 500 | principal, |
| 501 | git_cmd.as_str(), | ||
| 502 | resolved_path | ||
| 248 | ); | 503 | ); |
| 249 | session.exit_status_request(channel, 1); | 504 | session.exit_status_request(channel, 1); |
| 250 | session.eof(channel); | 505 | session.eof(channel); |
| @@ -252,7 +507,7 @@ impl Handler for SshHandler { | |||
| 252 | return Ok(()); | 507 | return Ok(()); |
| 253 | } | 508 | } |
| 254 | } else { | 509 | } else { |
| 255 | match ensure_repo_exists_for_command(&git_cmd, &resolved_path) { | 510 | match ensure_repo_exists_for_command(git_cmd, &resolved_path) { |
| 256 | Ok(true) => { | 511 | Ok(true) => { |
| 257 | info!("Created bare repo for receive-pack: {:?}", resolved_path); | 512 | info!("Created bare repo for receive-pack: {:?}", resolved_path); |
| 258 | } | 513 | } |
| @@ -282,11 +537,8 @@ impl Handler for SshHandler { | |||
| 282 | 537 | ||
| 283 | // Spawn the git subprocess | 538 | // Spawn the git subprocess |
| 284 | let handle = session.handle(); | 539 | let handle = session.handle(); |
| 285 | let git_cmd_owned = git_cmd; | ||
| 286 | tokio::spawn(async move { | 540 | tokio::spawn(async move { |
| 287 | if let Err(e) = | 541 | if let Err(e) = run_git_command(handle, channel, git_cmd, &resolved_path, rx).await { |
| 288 | run_git_command(handle, channel, &git_cmd_owned, &resolved_path, rx).await | ||
| 289 | { | ||
| 290 | error!("Git subprocess error: {}", e); | 542 | error!("Git subprocess error: {}", e); |
| 291 | } | 543 | } |
| 292 | }); | 544 | }); |
| @@ -296,10 +548,25 @@ impl Handler for SshHandler { | |||
| 296 | 548 | ||
| 297 | async fn data( | 549 | async fn data( |
| 298 | &mut self, | 550 | &mut self, |
| 299 | _channel: ChannelId, | 551 | channel: ChannelId, |
| 300 | data: &[u8], | 552 | data: &[u8], |
| 301 | _session: &mut Session, | 553 | _session: &mut Session, |
| 302 | ) -> Result<(), Self::Error> { | 554 | ) -> Result<(), Self::Error> { |
| 555 | if let Some(upload) = self.active_upload.as_mut() { | ||
| 556 | if upload.channel == channel { | ||
| 557 | let state = | ||
| 558 | std::mem::replace(&mut upload.state, UploadState::Failed(String::new())); | ||
| 559 | upload.state = match state { | ||
| 560 | UploadState::Active(mut active) => match active.write(data) { | ||
| 561 | Ok(()) => UploadState::Active(active), | ||
| 562 | // Dropping `active` here discards the temp file. | ||
| 563 | Err(e) => UploadState::Failed(e.to_string()), | ||
| 564 | }, | ||
| 565 | failed => failed, | ||
| 566 | }; | ||
| 567 | return Ok(()); | ||
| 568 | } | ||
| 569 | } | ||
| 303 | // Forward client data to the git subprocess's stdin | 570 | // Forward client data to the git subprocess's stdin |
| 304 | if let Some(ref tx) = self.stdin_tx { | 571 | if let Some(ref tx) = self.stdin_tx { |
| 305 | if tx.send(data.to_vec()).await.is_err() { | 572 | if tx.send(data.to_vec()).await.is_err() { |
| @@ -308,18 +575,54 @@ impl Handler for SshHandler { | |||
| 308 | } | 575 | } |
| 309 | Ok(()) | 576 | Ok(()) |
| 310 | } | 577 | } |
| 578 | |||
| 579 | async fn channel_eof( | ||
| 580 | &mut self, | ||
| 581 | channel: ChannelId, | ||
| 582 | session: &mut Session, | ||
| 583 | ) -> Result<(), Self::Error> { | ||
| 584 | // Close the git subprocess's stdin, if any. | ||
| 585 | self.stdin_tx = None; | ||
| 586 | |||
| 587 | if let Some(upload) = self.active_upload.take() { | ||
| 588 | if upload.channel != channel { | ||
| 589 | self.active_upload = Some(upload); | ||
| 590 | return Ok(()); | ||
| 591 | } | ||
| 592 | match upload.state { | ||
| 593 | UploadState::Active(active) => match active.finish() { | ||
| 594 | Ok(sha) => reply_and_close(session, channel, &format!("ok {}\n", sha), 0), | ||
| 595 | Err(e) => reply_and_close(session, channel, &format!("error: {}\n", e), 1), | ||
| 596 | }, | ||
| 597 | UploadState::Failed(msg) => { | ||
| 598 | reply_and_close(session, channel, &format!("error: {}\n", msg), 1) | ||
| 599 | } | ||
| 600 | } | ||
| 601 | } | ||
| 602 | Ok(()) | ||
| 603 | } | ||
| 604 | } | ||
| 605 | |||
| 606 | /// Send a final message (if any), an exit status, and close the channel. | ||
| 607 | fn reply_and_close(session: &mut Session, channel: ChannelId, message: &str, exit_code: u32) { | ||
| 608 | if !message.is_empty() { | ||
| 609 | session.data(channel, CryptoVec::from_slice(message.as_bytes())); | ||
| 610 | } | ||
| 611 | session.exit_status_request(channel, exit_code); | ||
| 612 | session.eof(channel); | ||
| 613 | session.close(channel); | ||
| 311 | } | 614 | } |
| 312 | 615 | ||
| 313 | async fn run_git_command( | 616 | async fn run_git_command( |
| 314 | handle: russh::server::Handle, | 617 | handle: russh::server::Handle, |
| 315 | channel: ChannelId, | 618 | channel: ChannelId, |
| 316 | git_cmd: &str, | 619 | git_cmd: GitCmd, |
| 317 | repo_path: &Path, | 620 | repo_path: &Path, |
| 318 | mut stdin_rx: mpsc::Receiver<Vec<u8>>, | 621 | mut stdin_rx: mpsc::Receiver<Vec<u8>>, |
| 319 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | 622 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { |
| 320 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | 623 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 321 | 624 | ||
| 322 | let mut child = Command::new(git_cmd) | 625 | let mut child = Command::new(git_cmd.as_str()) |
| 323 | .arg(repo_path) | 626 | .arg(repo_path) |
| 324 | .stdin(std::process::Stdio::piped()) | 627 | .stdin(std::process::Stdio::piped()) |
| 325 | .stdout(std::process::Stdio::piped()) | 628 | .stdout(std::process::Stdio::piped()) |
| @@ -401,6 +704,146 @@ mod tests { | |||
| 401 | } | 704 | } |
| 402 | 705 | ||
| 403 | #[test] | 706 | #[test] |
| 707 | fn parse_release_upload() { | ||
| 708 | let cmd = parse_exec_command("collab-release upload 'myrepo.git' 'v1.0.0' 'app.tar.gz'"); | ||
| 709 | assert_eq!( | ||
| 710 | cmd, | ||
| 711 | Some(ExecCommand::Release(ReleaseCmd::Upload { | ||
| 712 | repo: "myrepo.git".into(), | ||
| 713 | version: "v1.0.0".into(), | ||
| 714 | filename: "app.tar.gz".into(), | ||
| 715 | force: false, | ||
| 716 | })) | ||
| 717 | ); | ||
| 718 | } | ||
| 719 | |||
| 720 | #[test] | ||
| 721 | fn parse_release_upload_force() { | ||
| 722 | let cmd = parse_exec_command("collab-release upload 'myrepo.git' 'v1' 'a.tar.gz' --force"); | ||
| 723 | assert_eq!( | ||
| 724 | cmd, | ||
| 725 | Some(ExecCommand::Release(ReleaseCmd::Upload { | ||
| 726 | repo: "myrepo.git".into(), | ||
| 727 | version: "v1".into(), | ||
| 728 | filename: "a.tar.gz".into(), | ||
| 729 | force: true, | ||
| 730 | })) | ||
| 731 | ); | ||
| 732 | } | ||
| 733 | |||
| 734 | #[test] | ||
| 735 | fn parse_release_list_and_delete() { | ||
| 736 | assert_eq!( | ||
| 737 | parse_exec_command("collab-release list 'myrepo.git'"), | ||
| 738 | Some(ExecCommand::Release(ReleaseCmd::List { | ||
| 739 | repo: "myrepo.git".into() | ||
| 740 | })) | ||
| 741 | ); | ||
| 742 | assert_eq!( | ||
| 743 | parse_exec_command("collab-release delete 'myrepo.git' 'v1'"), | ||
| 744 | Some(ExecCommand::Release(ReleaseCmd::Delete { | ||
| 745 | repo: "myrepo.git".into(), | ||
| 746 | version: "v1".into(), | ||
| 747 | filename: None, | ||
| 748 | })) | ||
| 749 | ); | ||
| 750 | assert_eq!( | ||
| 751 | parse_exec_command("collab-release delete 'myrepo.git' 'v1' 'a.tar.gz'"), | ||
| 752 | Some(ExecCommand::Release(ReleaseCmd::Delete { | ||
| 753 | repo: "myrepo.git".into(), | ||
| 754 | version: "v1".into(), | ||
| 755 | filename: Some("a.tar.gz".into()), | ||
| 756 | })) | ||
| 757 | ); | ||
| 758 | } | ||
| 759 | |||
| 760 | #[test] | ||
| 761 | fn parse_exec_command_handles_git_commands() { | ||
| 762 | assert_eq!( | ||
| 763 | parse_exec_command("git-upload-pack '/srv/git/repo.git'"), | ||
| 764 | Some(ExecCommand::Git { | ||
| 765 | cmd: GitCmd::UploadPack, | ||
| 766 | repo: "/srv/git/repo.git".into() | ||
| 767 | }) | ||
| 768 | ); | ||
| 769 | assert_eq!( | ||
| 770 | parse_exec_command("git-receive-pack '/srv/git/repo.git'"), | ||
| 771 | Some(ExecCommand::Git { | ||
| 772 | cmd: GitCmd::ReceivePack, | ||
| 773 | repo: "/srv/git/repo.git".into() | ||
| 774 | }) | ||
| 775 | ); | ||
| 776 | } | ||
| 777 | |||
| 778 | #[test] | ||
| 779 | fn exec_command_repo_accessor() { | ||
| 780 | assert_eq!( | ||
| 781 | parse_exec_command("git-upload-pack '/srv/git/repo.git'") | ||
| 782 | .unwrap() | ||
| 783 | .repo(), | ||
| 784 | "/srv/git/repo.git" | ||
| 785 | ); | ||
| 786 | assert_eq!( | ||
| 787 | parse_exec_command("collab-release upload 'r.git' 'v1' 'a.tar.gz'") | ||
| 788 | .unwrap() | ||
| 789 | .repo(), | ||
| 790 | "r.git" | ||
| 791 | ); | ||
| 792 | } | ||
| 793 | |||
| 794 | #[test] | ||
| 795 | fn parse_release_rejects_malformed() { | ||
| 796 | assert_eq!(parse_exec_command("collab-release"), None); | ||
| 797 | assert_eq!(parse_exec_command("collab-release frobnicate 'r'"), None); | ||
| 798 | assert_eq!(parse_exec_command("collab-release upload 'r'"), None); | ||
| 799 | assert_eq!(parse_exec_command("collab-release upload 'r' 'v'"), None); | ||
| 800 | assert_eq!( | ||
| 801 | parse_exec_command("collab-release upload 'r' 'v' 'f' --frob"), | ||
| 802 | None | ||
| 803 | ); | ||
| 804 | assert_eq!(parse_exec_command("collab-release list 'r' extra"), None); | ||
| 805 | assert_eq!(parse_exec_command("collab-release upload 'unclosed"), None); | ||
| 806 | assert_eq!(parse_exec_command("collab-release upload \"unclosed"), None); | ||
| 807 | assert_eq!(parse_exec_command("rm -rf /"), None); | ||
| 808 | } | ||
| 809 | |||
| 810 | #[test] | ||
| 811 | fn parse_release_rejects_empty_args() { | ||
| 812 | assert_eq!(parse_exec_command("collab-release list ''"), None); | ||
| 813 | assert_eq!(parse_exec_command("collab-release upload '' 'v' 'f'"), None); | ||
| 814 | assert_eq!(parse_exec_command("collab-release upload 'r' '' 'f'"), None); | ||
| 815 | assert_eq!(parse_exec_command(""), None); | ||
| 816 | assert_eq!(parse_exec_command(" "), None); | ||
| 817 | } | ||
| 818 | |||
| 819 | #[test] | ||
| 820 | fn parse_delete_force_is_filename_not_flag() { | ||
| 821 | // `--force` in the filename slot for `delete` is just a filename, | ||
| 822 | // not a flag (delete has no force flag); it is rejected downstream | ||
| 823 | // by validate_name (leading dash), not by this parser. | ||
| 824 | assert_eq!( | ||
| 825 | parse_exec_command("collab-release delete 'r' 'v' '--force'"), | ||
| 826 | Some(ExecCommand::Release(ReleaseCmd::Delete { | ||
| 827 | repo: "r".into(), | ||
| 828 | version: "v".into(), | ||
| 829 | filename: Some("--force".into()), | ||
| 830 | })) | ||
| 831 | ); | ||
| 832 | } | ||
| 833 | |||
| 834 | #[test] | ||
| 835 | fn parse_release_accepts_traversal_repo_arg() { | ||
| 836 | // The parser does not validate the repo argument's contents; | ||
| 837 | // resolve_repo_path/entry_for_path are the gate against traversal. | ||
| 838 | assert_eq!( | ||
| 839 | parse_exec_command("collab-release list '../../etc'"), | ||
| 840 | Some(ExecCommand::Release(ReleaseCmd::List { | ||
| 841 | repo: "../../etc".into() | ||
| 842 | })) | ||
| 843 | ); | ||
| 844 | } | ||
| 845 | |||
| 846 | #[test] | ||
| 404 | fn resolve_simple_path() { | 847 | fn resolve_simple_path() { |
| 405 | let repos_dir = Path::new("/srv/git"); | 848 | let repos_dir = Path::new("/srv/git"); |
| 406 | let result = resolve_repo_path(repos_dir, "/repo.git"); | 849 | let result = resolve_repo_path(repos_dir, "/repo.git"); |
| @@ -437,7 +880,7 @@ mod tests { | |||
| 437 | let tmp = TempDir::new().unwrap(); | 880 | let tmp = TempDir::new().unwrap(); |
| 438 | let repo_path = tmp.path().join("org").join("new-repo.git"); | 881 | let repo_path = tmp.path().join("org").join("new-repo.git"); |
| 439 | 882 | ||
| 440 | let created = ensure_repo_exists_for_command("git-receive-pack", &repo_path).unwrap(); | 883 | let created = ensure_repo_exists_for_command(GitCmd::ReceivePack, &repo_path).unwrap(); |
| 441 | 884 | ||
| 442 | assert!(created); | 885 | assert!(created); |
| 443 | assert!(repo_path.exists()); | 886 | assert!(repo_path.exists()); |
| @@ -450,7 +893,7 @@ mod tests { | |||
| 450 | let tmp = TempDir::new().unwrap(); | 893 | let tmp = TempDir::new().unwrap(); |
| 451 | let repo_path = tmp.path().join("org").join("missing.git"); | 894 | let repo_path = tmp.path().join("org").join("missing.git"); |
| 452 | 895 | ||
| 453 | let created = ensure_repo_exists_for_command("git-upload-pack", &repo_path).unwrap(); | 896 | let created = ensure_repo_exists_for_command(GitCmd::UploadPack, &repo_path).unwrap(); |
| 454 | 897 | ||
| 455 | assert!(!created); | 898 | assert!(!created); |
| 456 | assert!(!repo_path.exists()); | 899 | assert!(!repo_path.exists()); |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -503,10 +503,17 @@ pub struct ServerHarness { | |||
| 503 | work_repo: TestRepo, | 503 | work_repo: TestRepo, |
| 504 | server: Child, | 504 | server: Child, |
| 505 | http_addr: SocketAddr, | 505 | http_addr: SocketAddr, |
| 506 | ssh_addr: SocketAddr, | ||
| 506 | } | 507 | } |
| 507 | 508 | ||
| 508 | impl ServerHarness { | 509 | impl ServerHarness { |
| 509 | pub fn new(repo_name: &str) -> Self { | 510 | pub fn new(repo_name: &str) -> Self { |
| 511 | Self::new_with_extra_config(repo_name, "") | ||
| 512 | } | ||
| 513 | |||
| 514 | /// Like `new`, but appends extra lines to the server config | ||
| 515 | /// (e.g. "max_release_size = 1024"). | ||
| 516 | pub fn new_with_extra_config(repo_name: &str, extra_config: &str) -> Self { | ||
| 510 | let root = TempDir::new().unwrap(); | 517 | let root = TempDir::new().unwrap(); |
| 511 | let repos_dir = root.path().join("repos"); | 518 | let repos_dir = root.path().join("repos"); |
| 512 | std::fs::create_dir_all(&repos_dir).unwrap(); | 519 | std::fs::create_dir_all(&repos_dir).unwrap(); |
| @@ -529,11 +536,12 @@ impl ServerHarness { | |||
| 529 | std::fs::write( | 536 | std::fs::write( |
| 530 | &config_path, | 537 | &config_path, |
| 531 | format!( | 538 | format!( |
| 532 | "repos_dir = {:?}\nhttp_bind = \"{}\"\nssh_bind = \"{}\"\nauthorized_keys = {:?}\nsite_title = \"git-collab test\"\n", | 539 | "repos_dir = {:?}\nhttp_bind = \"{}\"\nssh_bind = \"{}\"\nauthorized_keys = {:?}\nsite_title = \"git-collab test\"\n{}", |
| 533 | repos_dir, | 540 | repos_dir, |
| 534 | http_addr, | 541 | http_addr, |
| 535 | ssh_addr, | 542 | ssh_addr, |
| 536 | authorized_keys, | 543 | authorized_keys, |
| 544 | extra_config, | ||
| 537 | ), | 545 | ), |
| 538 | ) | 546 | ) |
| 539 | .unwrap(); | 547 | .unwrap(); |
| @@ -551,6 +559,7 @@ impl ServerHarness { | |||
| 551 | work_repo, | 559 | work_repo, |
| 552 | server, | 560 | server, |
| 553 | http_addr, | 561 | http_addr, |
| 562 | ssh_addr, | ||
| 554 | }; | 563 | }; |
| 555 | harness.wait_until_ready(); | 564 | harness.wait_until_ready(); |
| 556 | harness | 565 | harness |
| @@ -560,6 +569,125 @@ impl ServerHarness { | |||
| 560 | &self.repo_name | 569 | &self.repo_name |
| 561 | } | 570 | } |
| 562 | 571 | ||
| 572 | /// Path to the server's repos dir (for on-disk assertions). | ||
| 573 | pub fn repos_dir(&self) -> PathBuf { | ||
| 574 | self.root.path().join("repos") | ||
| 575 | } | ||
| 576 | |||
| 577 | /// Generate a client SSH keypair (once) and authorize it. Returns the key path. | ||
| 578 | /// | ||
| 579 | /// Not thread-safe: writes shared state under the harness root | ||
| 580 | /// (`id_ed25519`, `authorized_keys`). Fine for the one-harness-per-test | ||
| 581 | /// usage this suite follows, but don't share a harness across threads. | ||
| 582 | pub fn ssh_client_key(&self) -> PathBuf { | ||
| 583 | let key_path = self.root.path().join("id_ed25519"); | ||
| 584 | if !key_path.exists() { | ||
| 585 | let output = Command::new("ssh-keygen") | ||
| 586 | .args([ | ||
| 587 | "-t", | ||
| 588 | "ed25519", | ||
| 589 | "-N", | ||
| 590 | "", | ||
| 591 | "-q", | ||
| 592 | "-f", | ||
| 593 | key_path.to_str().unwrap(), | ||
| 594 | ]) | ||
| 595 | .output() | ||
| 596 | .expect("failed to run ssh-keygen"); | ||
| 597 | assert!( | ||
| 598 | output.status.success(), | ||
| 599 | "ssh-keygen failed: {}", | ||
| 600 | String::from_utf8_lossy(&output.stderr) | ||
| 601 | ); | ||
| 602 | let pubkey = std::fs::read_to_string(key_path.with_extension("pub")).unwrap(); | ||
| 603 | std::fs::write(self.root.path().join("authorized_keys"), pubkey).unwrap(); | ||
| 604 | } | ||
| 605 | key_path | ||
| 606 | } | ||
| 607 | |||
| 608 | /// The ssh client options needed to reach this test server, as a single | ||
| 609 | /// command string usable both directly and as GIT_COLLAB_SSH_COMMAND. | ||
| 610 | pub fn ssh_command_string(&self) -> String { | ||
| 611 | format!( | ||
| 612 | "ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5", | ||
| 613 | self.ssh_client_key().display() | ||
| 614 | ) | ||
| 615 | } | ||
| 616 | |||
| 617 | /// Run a remote command over SSH with the given stdin bytes. | ||
| 618 | /// | ||
| 619 | /// Writes stdin from a separate thread so stdout/stderr are drained | ||
| 620 | /// concurrently (avoids the classic pipe deadlock where the child blocks | ||
| 621 | /// writing to a full stdout/stderr pipe while we're still blocked writing | ||
| 622 | /// stdin). Also enforces a 60s watchdog: if the server hangs, the ssh | ||
| 623 | /// child is killed rather than hanging the test/CI forever. | ||
| 624 | pub fn ssh_exec_with_stdin(&self, remote_cmd: &str, stdin: &[u8]) -> Output { | ||
| 625 | let key = self.ssh_client_key(); | ||
| 626 | let mut child = Command::new("ssh") | ||
| 627 | .args([ | ||
| 628 | "-p", | ||
| 629 | &self.ssh_addr.port().to_string(), | ||
| 630 | "-i", | ||
| 631 | key.to_str().unwrap(), | ||
| 632 | "-o", | ||
| 633 | "StrictHostKeyChecking=no", | ||
| 634 | "-o", | ||
| 635 | "UserKnownHostsFile=/dev/null", | ||
| 636 | "-o", | ||
| 637 | "IdentitiesOnly=yes", | ||
| 638 | "-o", | ||
| 639 | "BatchMode=yes", | ||
| 640 | "-o", | ||
| 641 | "ConnectTimeout=5", | ||
| 642 | "git@127.0.0.1", | ||
| 643 | remote_cmd, | ||
| 644 | ]) | ||
| 645 | .stdin(Stdio::piped()) | ||
| 646 | .stdout(Stdio::piped()) | ||
| 647 | .stderr(Stdio::piped()) | ||
| 648 | .spawn() | ||
| 649 | .expect("failed to spawn ssh"); | ||
| 650 | |||
| 651 | let mut stdin_handle = child.stdin.take().unwrap(); | ||
| 652 | let stdin_bytes = stdin.to_vec(); | ||
| 653 | let writer = std::thread::spawn(move || { | ||
| 654 | // Ignore write errors: the child may legitimately exit before | ||
| 655 | // reading all stdin (e.g. on early rejection). The caller | ||
| 656 | // asserts on exit status/output, not on this write succeeding. | ||
| 657 | let _ = stdin_handle.write_all(&stdin_bytes); | ||
| 658 | // handle drops here -> EOF on the channel | ||
| 659 | }); | ||
| 660 | |||
| 661 | let deadline = Instant::now() + Duration::from_secs(60); | ||
| 662 | loop { | ||
| 663 | if child.try_wait().expect("failed to poll ssh process").is_some() { | ||
| 664 | break; | ||
| 665 | } | ||
| 666 | if Instant::now() >= deadline { | ||
| 667 | let _ = child.kill(); | ||
| 668 | break; | ||
| 669 | } | ||
| 670 | thread::sleep(Duration::from_millis(50)); | ||
| 671 | } | ||
| 672 | |||
| 673 | let output = child.wait_with_output().expect("failed to wait for ssh"); | ||
| 674 | writer.join().expect("stdin writer thread panicked"); | ||
| 675 | output | ||
| 676 | } | ||
| 677 | |||
| 678 | pub fn ssh_exec(&self, remote_cmd: &str) -> Output { | ||
| 679 | self.ssh_exec_with_stdin(remote_cmd, b"") | ||
| 680 | } | ||
| 681 | |||
| 682 | /// ssh:// URL for the harness repo, for use as a git-collab remote. | ||
| 683 | pub fn repo_ssh_url(&self) -> String { | ||
| 684 | format!( | ||
| 685 | "ssh://git@127.0.0.1:{}/{}.git", | ||
| 686 | self.ssh_addr.port(), | ||
| 687 | self.repo_name | ||
| 688 | ) | ||
| 689 | } | ||
| 690 | |||
| 563 | pub fn work_repo(&self) -> &TestRepo { | 691 | pub fn work_repo(&self) -> &TestRepo { |
| 564 | &self.work_repo | 692 | &self.work_repo |
| 565 | } | 693 | } |
| @@ -623,6 +751,33 @@ impl ServerHarness { | |||
| 623 | } | 751 | } |
| 624 | } | 752 | } |
| 625 | 753 | ||
| 754 | /// Like `get`, but returns the raw body bytes and full header block. | ||
| 755 | pub fn get_bytes(&self, path: &str) -> (String, Vec<u8>) { | ||
| 756 | let mut stream = TcpStream::connect(self.http_addr).unwrap_or_else(|e| { | ||
| 757 | panic!( | ||
| 758 | "failed to connect to http server on {}: {}", | ||
| 759 | self.http_addr, e | ||
| 760 | ) | ||
| 761 | }); | ||
| 762 | stream | ||
| 763 | .write_all( | ||
| 764 | format!( | ||
| 765 | "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", | ||
| 766 | path, self.http_addr | ||
| 767 | ) | ||
| 768 | .as_bytes(), | ||
| 769 | ) | ||
| 770 | .unwrap(); | ||
| 771 | let mut raw = Vec::new(); | ||
| 772 | stream.read_to_end(&mut raw).unwrap(); | ||
| 773 | let split = raw | ||
| 774 | .windows(4) | ||
| 775 | .position(|w| w == b"\r\n\r\n") | ||
| 776 | .expect("no header/body separator"); | ||
| 777 | let head = String::from_utf8_lossy(&raw[..split]).to_string(); | ||
| 778 | (head, raw[split + 4..].to_vec()) | ||
| 779 | } | ||
| 780 | |||
| 626 | fn wait_until_ready(&mut self) { | 781 | fn wait_until_ready(&mut self) { |
| 627 | let deadline = Instant::now() + Duration::from_secs(10); | 782 | let deadline = Instant::now() + Duration::from_secs(10); |
| 628 | loop { | 783 | loop { |
| @@ -633,15 +788,15 @@ impl ServerHarness { | |||
| 633 | if let Ok(response) = TcpStream::connect(self.http_addr) { | 788 | if let Ok(response) = TcpStream::connect(self.http_addr) { |
| 634 | drop(response); | 789 | drop(response); |
| 635 | let response = self.get("/"); | 790 | let response = self.get("/"); |
| 636 | if !response.status_line.is_empty() { | 791 | if !response.status_line.is_empty() && TcpStream::connect(self.ssh_addr).is_ok() { |
| 637 | return; | 792 | return; |
| 638 | } | 793 | } |
| 639 | } | 794 | } |
| 640 | 795 | ||
| 641 | if Instant::now() >= deadline { | 796 | if Instant::now() >= deadline { |
| 642 | panic!( | 797 | panic!( |
| 643 | "timed out waiting for git-collab-server on {}", | 798 | "timed out waiting for git-collab-server on http {} / ssh {}", |
| 644 | self.http_addr | 799 | self.http_addr, self.ssh_addr |
| 645 | ); | 800 | ); |
| 646 | } | 801 | } |
| 647 | 802 | ||
tests/release_server_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,221 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use common::ServerHarness; | ||
| 4 | |||
| 5 | #[test] | ||
| 6 | fn ssh_client_interop_rejects_unknown_command() { | ||
| 7 | let harness = ServerHarness::new("release-canary"); | ||
| 8 | harness.push_head(); | ||
| 9 | |||
| 10 | let output = harness.ssh_exec("frobnicate"); | ||
| 11 | assert!( | ||
| 12 | !output.status.success(), | ||
| 13 | "unknown exec command must fail, got: {}", | ||
| 14 | String::from_utf8_lossy(&output.stdout) | ||
| 15 | ); | ||
| 16 | |||
| 17 | // A bogus exec command failing isn't proof the interop worked - ssh itself | ||
| 18 | // failing to connect/authenticate would also produce a non-zero exit. Rule | ||
| 19 | // that out explicitly so this canary actually proves client<->server auth | ||
| 20 | // and exec dispatch succeeded. | ||
| 21 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 22 | assert!( | ||
| 23 | !stderr.contains("Permission denied"), | ||
| 24 | "ssh authentication failed, interop is broken: {}", | ||
| 25 | stderr | ||
| 26 | ); | ||
| 27 | assert!( | ||
| 28 | !stderr.contains("Connection refused"), | ||
| 29 | "ssh failed to connect, interop is broken: {}", | ||
| 30 | stderr | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | |||
| 34 | use std::process::Output; | ||
| 35 | |||
| 36 | fn stdout(output: &Output) -> String { | ||
| 37 | String::from_utf8_lossy(&output.stdout).to_string() | ||
| 38 | } | ||
| 39 | |||
| 40 | fn assert_ssh_error(output: &Output, needle: &str) { | ||
| 41 | assert!(!output.status.success(), "expected failure, got success"); | ||
| 42 | let all = format!( | ||
| 43 | "{}{}", | ||
| 44 | String::from_utf8_lossy(&output.stdout), | ||
| 45 | String::from_utf8_lossy(&output.stderr) | ||
| 46 | ); | ||
| 47 | assert!( | ||
| 48 | all.contains(needle), | ||
| 49 | "expected '{}' in output: {}", | ||
| 50 | needle, | ||
| 51 | all | ||
| 52 | ); | ||
| 53 | } | ||
| 54 | |||
| 55 | #[test] | ||
| 56 | fn upload_stores_file_with_checksum() { | ||
| 57 | let harness = ServerHarness::new("release-upload"); | ||
| 58 | harness.push_head(); | ||
| 59 | |||
| 60 | let content = b"fake tarball bytes"; | ||
| 61 | let output = harness.ssh_exec_with_stdin( | ||
| 62 | "collab-release upload 'release-upload.git' 'v1.0.0' 'app.tar.gz'", | ||
| 63 | content, | ||
| 64 | ); | ||
| 65 | assert!(output.status.success(), "upload failed: {:?}", output); | ||
| 66 | |||
| 67 | // reply is "ok <sha256>" | ||
| 68 | let reply = stdout(&output); | ||
| 69 | let sha = reply | ||
| 70 | .trim() | ||
| 71 | .strip_prefix("ok ") | ||
| 72 | .expect("reply not 'ok <sha>'"); | ||
| 73 | |||
| 74 | use sha2::Digest; | ||
| 75 | let expected: String = sha2::Sha256::digest(content) | ||
| 76 | .iter() | ||
| 77 | .map(|b| format!("{:02x}", b)) | ||
| 78 | .collect(); | ||
| 79 | assert_eq!(sha, expected); | ||
| 80 | |||
| 81 | let stored = harness | ||
| 82 | .repos_dir() | ||
| 83 | .join("release-upload.git/collab/releases/v1.0.0/app.tar.gz"); | ||
| 84 | assert_eq!(std::fs::read(&stored).unwrap(), content); | ||
| 85 | assert!(stored.with_file_name("app.tar.gz.sha256").exists()); | ||
| 86 | } | ||
| 87 | |||
| 88 | #[test] | ||
| 89 | fn duplicate_upload_needs_force() { | ||
| 90 | let harness = ServerHarness::new("release-dup"); | ||
| 91 | harness.push_head(); | ||
| 92 | let cmd = "collab-release upload 'release-dup.git' 'v1' 'a.tar.gz'"; | ||
| 93 | |||
| 94 | assert!(harness.ssh_exec_with_stdin(cmd, b"one").status.success()); | ||
| 95 | let dup = harness.ssh_exec_with_stdin(cmd, b"two"); | ||
| 96 | assert_ssh_error(&dup, "already exists"); | ||
| 97 | |||
| 98 | let forced = harness.ssh_exec_with_stdin( | ||
| 99 | "collab-release upload 'release-dup.git' 'v1' 'a.tar.gz' --force", | ||
| 100 | b"two", | ||
| 101 | ); | ||
| 102 | assert!( | ||
| 103 | forced.status.success(), | ||
| 104 | "forced upload failed: {:?}", | ||
| 105 | forced | ||
| 106 | ); | ||
| 107 | let stored = harness | ||
| 108 | .repos_dir() | ||
| 109 | .join("release-dup.git/collab/releases/v1/a.tar.gz"); | ||
| 110 | assert_eq!(std::fs::read(&stored).unwrap(), b"two"); | ||
| 111 | } | ||
| 112 | |||
| 113 | #[test] | ||
| 114 | fn list_returns_json_index() { | ||
| 115 | let harness = ServerHarness::new("release-list"); | ||
| 116 | harness.push_head(); | ||
| 117 | |||
| 118 | harness.ssh_exec_with_stdin( | ||
| 119 | "collab-release upload 'release-list.git' 'v1.0.0' 'a.tar.gz'", | ||
| 120 | b"aaa", | ||
| 121 | ); | ||
| 122 | let output = harness.ssh_exec("collab-release list 'release-list.git'"); | ||
| 123 | assert!(output.status.success()); | ||
| 124 | |||
| 125 | let index: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); | ||
| 126 | let versions = index["versions"].as_array().unwrap(); | ||
| 127 | assert_eq!(versions.len(), 1); | ||
| 128 | assert_eq!(versions[0]["version"], "v1.0.0"); | ||
| 129 | assert_eq!(versions[0]["files"][0]["name"], "a.tar.gz"); | ||
| 130 | assert_eq!(versions[0]["files"][0]["size"], 3); | ||
| 131 | assert_eq!( | ||
| 132 | versions[0]["files"][0]["sha256"].as_str().unwrap().len(), | ||
| 133 | 64 | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | |||
| 137 | #[test] | ||
| 138 | fn delete_removes_file_then_version() { | ||
| 139 | let harness = ServerHarness::new("release-del"); | ||
| 140 | harness.push_head(); | ||
| 141 | |||
| 142 | harness.ssh_exec_with_stdin( | ||
| 143 | "collab-release upload 'release-del.git' 'v1' 'a.tar.gz'", | ||
| 144 | b"a", | ||
| 145 | ); | ||
| 146 | harness.ssh_exec_with_stdin( | ||
| 147 | "collab-release upload 'release-del.git' 'v1' 'b.tar.gz'", | ||
| 148 | b"b", | ||
| 149 | ); | ||
| 150 | |||
| 151 | let releases = harness.repos_dir().join("release-del.git/collab/releases"); | ||
| 152 | |||
| 153 | let del_file = harness.ssh_exec("collab-release delete 'release-del.git' 'v1' 'a.tar.gz'"); | ||
| 154 | assert!(del_file.status.success()); | ||
| 155 | assert!(!releases.join("v1/a.tar.gz").exists()); | ||
| 156 | assert!(releases.join("v1/b.tar.gz").exists()); | ||
| 157 | |||
| 158 | let del_version = harness.ssh_exec("collab-release delete 'release-del.git' 'v1'"); | ||
| 159 | assert!(del_version.status.success()); | ||
| 160 | assert!(!releases.join("v1").exists()); | ||
| 161 | |||
| 162 | let missing = harness.ssh_exec("collab-release delete 'release-del.git' 'v1'"); | ||
| 163 | assert_ssh_error(&missing, "not found"); | ||
| 164 | } | ||
| 165 | |||
| 166 | #[test] | ||
| 167 | fn write_policy_gates_upload_and_delete_but_not_list() { | ||
| 168 | let harness = ServerHarness::new("release-policy"); | ||
| 169 | harness.push_head(); | ||
| 170 | harness.write_repo_server_policy( | ||
| 171 | "visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n", | ||
| 172 | ); | ||
| 173 | |||
| 174 | let upload = harness.ssh_exec_with_stdin( | ||
| 175 | "collab-release upload 'release-policy.git' 'v1' 'a.tar.gz'", | ||
| 176 | b"x", | ||
| 177 | ); | ||
| 178 | assert!(!upload.status.success(), "upload must be denied"); | ||
| 179 | |||
| 180 | let delete = harness.ssh_exec("collab-release delete 'release-policy.git' 'v1'"); | ||
| 181 | assert!(!delete.status.success(), "delete must be denied"); | ||
| 182 | |||
| 183 | let list = harness.ssh_exec("collab-release list 'release-policy.git'"); | ||
| 184 | assert!(list.status.success(), "list must be allowed for readers"); | ||
| 185 | } | ||
| 186 | |||
| 187 | #[test] | ||
| 188 | fn oversize_upload_rejected_without_partial_file() { | ||
| 189 | let harness = ServerHarness::new_with_extra_config("release-size", "max_release_size = 16\n"); | ||
| 190 | harness.push_head(); | ||
| 191 | |||
| 192 | let output = harness.ssh_exec_with_stdin( | ||
| 193 | "collab-release upload 'release-size.git' 'v1' 'big.tar.gz'", | ||
| 194 | &[0u8; 64], | ||
| 195 | ); | ||
| 196 | assert_ssh_error(&output, "maximum release size"); | ||
| 197 | |||
| 198 | let version_dir = harness | ||
| 199 | .repos_dir() | ||
| 200 | .join("release-size.git/collab/releases/v1"); | ||
| 201 | assert!(!version_dir.join("big.tar.gz").exists()); | ||
| 202 | if version_dir.exists() { | ||
| 203 | assert_eq!(std::fs::read_dir(&version_dir).unwrap().count(), 0); | ||
| 204 | } | ||
| 205 | } | ||
| 206 | |||
| 207 | #[test] | ||
| 208 | fn invalid_names_and_unknown_repo_rejected() { | ||
| 209 | let harness = ServerHarness::new("release-invalid"); | ||
| 210 | harness.push_head(); | ||
| 211 | |||
| 212 | let traversal = harness.ssh_exec_with_stdin( | ||
| 213 | "collab-release upload 'release-invalid.git' '../evil' 'a.tar.gz'", | ||
| 214 | b"x", | ||
| 215 | ); | ||
| 216 | assert!(!traversal.status.success()); | ||
| 217 | |||
| 218 | let unknown = | ||
| 219 | harness.ssh_exec_with_stdin("collab-release upload 'nope.git' 'v1' 'a.tar.gz'", b"x"); | ||
| 220 | assert!(!unknown.status.success()); | ||
| 221 | } | ||