a73x

4255bcdb

Handle release upload, list and delete over SSH

a73x   2026-08-08 15:08

Commit message
Handle release upload, list and delete over SSH

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 }