a73x

753309fe

Guard concurrent uploads and abort oversize streams early

a73x   2026-08-08 16:07

Commit message
Guard concurrent uploads and abort oversize streams early

docs/superpowers/plans/2026-08-08-release-packages.md
Old New
@@ -1879,6 +1879,9 @@ git commit -m "Serve release listings and downloads over HTTP"
1879 1879
1880 ### Task 8: CLI `git-collab release` commands 1880 ### Task 8: CLI `git-collab release` commands
1881 1881
1882 **Execution deviations:**
1883 - `ssh_command`: dropped the plan's `.to_string()` on `parts.next().unwrap_or("ssh")` when building `Command::new(...)` — clippy's `unnecessary_to_owned` flagged it as a new warning; `Command::new` already accepts `&str`.
1884
1882 **Files:** 1885 **Files:**
1883 - Modify: `src/release.rs` (remote parsing + command execution) 1886 - Modify: `src/release.rs` (remote parsing + command execution)
1884 - Modify: `src/cli.rs` (subcommand) 1887 - Modify: `src/cli.rs` (subcommand)
src/release.rs
Old New
@@ -33,6 +33,9 @@ pub struct SshRemote {
33 } 33 }
34 34
35 /// Parse `ssh://[user@]host[:port]/path` or scp-style `[user@]host:path`. 35 /// Parse `ssh://[user@]host[:port]/path` or scp-style `[user@]host:path`.
36 ///
37 /// Note: IPv6 bracket addresses (`ssh://[::1]:2222/path`) are not specially
38 /// handled and will mis-parse rather than fail closed — out of scope per spec.
36 pub fn parse_ssh_remote(url: &str) -> Option<SshRemote> { 39 pub fn parse_ssh_remote(url: &str) -> Option<SshRemote> {
37 if let Some(rest) = url.strip_prefix("ssh://") { 40 if let Some(rest) = url.strip_prefix("ssh://") {
38 let (authority, path) = rest.split_once('/')?; 41 let (authority, path) = rest.split_once('/')?;
@@ -82,16 +85,34 @@ fn ssh_remote(repo: &Repository, remote_name: &str) -> Result<SshRemote, Error>
82 let url = remote 85 let url = remote
83 .url() 86 .url()
84 .ok_or_else(|| Error::Cmd(format!("remote '{}' has no URL", remote_name)))?; 87 .ok_or_else(|| Error::Cmd(format!("remote '{}' has no URL", remote_name)))?;
85 parse_ssh_remote(url).ok_or_else(|| { 88 let parsed = parse_ssh_remote(url).ok_or_else(|| {
86 Error::Cmd(format!( 89 Error::Cmd(format!(
87 "remote '{}' ({}) is not an SSH remote — releases need an ssh:// or user@host: remote", 90 "remote '{}' ({}) is not an SSH remote — releases need an ssh:// or user@host: remote",
88 remote_name, url 91 remote_name, url
89 )) 92 ))
90 }) 93 })?;
94 validate_remote_path(&parsed.path)?;
95 Ok(parsed)
96 }
97
98 /// Reject a remote path containing quote characters: our remote command strings
99 /// wrap arguments in single quotes with no escaping, so a quote in the path
100 /// would let it break out of its argument — turn that into a clear client-side
101 /// error instead of an opaque server-side rejection (or worse).
102 fn validate_remote_path(path: &str) -> Result<(), Error> {
103 if path.contains('\'') || path.contains('"') {
104 return Err(Error::Cmd(format!(
105 "remote path contains quotes, which release commands cannot escape: {}",
106 path
107 )));
108 }
109 Ok(())
91 } 110 }
92 111
93 /// Build the ssh invocation for a remote, honoring GIT_COLLAB_SSH_COMMAND 112 /// Build the ssh invocation for a remote, honoring GIT_COLLAB_SSH_COMMAND
94 /// (like git's GIT_SSH_COMMAND: extra words become leading arguments). 113 /// (like git's GIT_SSH_COMMAND: extra words become leading arguments). Unlike
114 /// git's GIT_SSH_COMMAND, which is run through `sh -c` and supports shell
115 /// quoting, this is split on whitespace only — no quoting support.
95 fn ssh_command(remote: &SshRemote) -> Command { 116 fn ssh_command(remote: &SshRemote) -> Command {
96 let base = std::env::var("GIT_COLLAB_SSH_COMMAND").unwrap_or_else(|_| "ssh".to_string()); 117 let base = std::env::var("GIT_COLLAB_SSH_COMMAND").unwrap_or_else(|_| "ssh".to_string());
97 let mut parts = base.split_whitespace(); 118 let mut parts = base.split_whitespace();
@@ -116,16 +137,23 @@ fn run_remote(remote: &SshRemote, remote_cmd: &str, stdin: Stdio) -> Result<Outp
116 .output() 137 .output()
117 .map_err(|e| Error::Cmd(format!("failed to run ssh: {}", e)))?; 138 .map_err(|e| Error::Cmd(format!("failed to run ssh: {}", e)))?;
118 if !output.status.success() { 139 if !output.status.success() {
119 let msg = format!( 140 // Protocol errors from our server always arrive on the exec channel's
120 "{}{}", 141 // stdout; stderr is ssh's own banners (e.g. host-key warnings). Never
121 String::from_utf8_lossy(&output.stdout).trim(), 142 // concatenate the two — that glues an unrelated banner onto the
122 String::from_utf8_lossy(&output.stderr).trim() 143 // message. Prefer stdout, fall back to stderr, then a generic message.
123 ); 144 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
124 return Err(Error::Cmd(if msg.is_empty() { 145 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
125 "server rejected the command".to_string() 146 let msg = if !stdout.is_empty() {
147 stdout
148 } else if !stderr.is_empty() {
149 stderr
126 } else { 150 } else {
127 msg 151 "server rejected the command".to_string()
128 })); 152 };
153 // Strip a leading "error: " so callers (main.rs prints "error: {}")
154 // don't double it up.
155 let msg = msg.strip_prefix("error: ").unwrap_or(&msg).to_string();
156 return Err(Error::Cmd(msg));
129 } 157 }
130 Ok(output) 158 Ok(output)
131 } 159 }
@@ -150,6 +178,15 @@ pub fn publish(
150 if !validate_name(&filename) { 178 if !validate_name(&filename) {
151 return Err(Error::Cmd(format!("invalid filename: {}", filename))); 179 return Err(Error::Cmd(format!("invalid filename: {}", filename)));
152 } 180 }
181 // Directories open successfully on Linux; only reads fail. Without
182 // this check we would hand the server an EISDIR-failing handle and
183 // publish an empty artifact under a real name.
184 if !file.is_file() {
185 return Err(Error::Cmd(format!(
186 "not a regular file: {}",
187 file.display()
188 )));
189 }
153 let handle = std::fs::File::open(file) 190 let handle = std::fs::File::open(file)
154 .map_err(|e| Error::Cmd(format!("cannot open {}: {}", file.display(), e)))?; 191 .map_err(|e| Error::Cmd(format!("cannot open {}: {}", file.display(), e)))?;
155 let mut remote_cmd = format!( 192 let mut remote_cmd = format!(
@@ -206,6 +243,14 @@ pub fn delete(
206 version: &str, 243 version: &str,
207 filename: Option<&str>, 244 filename: Option<&str>,
208 ) -> Result<(), Error> { 245 ) -> Result<(), Error> {
246 if !validate_name(version) {
247 return Err(Error::Cmd(format!("invalid version name: {}", version)));
248 }
249 if let Some(name) = filename {
250 if !validate_name(name) {
251 return Err(Error::Cmd(format!("invalid filename: {}", name)));
252 }
253 }
209 let remote = ssh_remote(repo, remote_name)?; 254 let remote = ssh_remote(repo, remote_name)?;
210 let mut remote_cmd = format!("collab-release delete '{}' '{}'", remote.path, version); 255 let mut remote_cmd = format!("collab-release delete '{}' '{}'", remote.path, version);
211 if let Some(name) = filename { 256 if let Some(name) = filename {
@@ -224,6 +269,26 @@ mod tests {
224 use super::*; 269 use super::*;
225 270
226 #[test] 271 #[test]
272 fn publish_rejects_non_regular_file() {
273 // A directory opens fine on Linux; without an explicit check we would
274 // stream an EISDIR-failing handle to the server as an empty artifact.
275 let tmp = tempfile::TempDir::new().unwrap();
276 let repo = Repository::init(tmp.path()).unwrap();
277 repo.remote("origin", "ssh://git@example.invalid/repo.git")
278 .unwrap();
279
280 let dir_that_looks_like_a_file = tmp.path().join("payload.tar.gz");
281 std::fs::create_dir(&dir_that_looks_like_a_file).unwrap();
282
283 let err = publish(&repo, "origin", "v1", &[dir_that_looks_like_a_file], false).unwrap_err();
284 assert!(
285 err.to_string().contains("not a regular file"),
286 "got: {}",
287 err
288 );
289 }
290
291 #[test]
227 fn accepts_typical_names() { 292 fn accepts_typical_names() {
228 assert!(validate_name("v1.2.0")); 293 assert!(validate_name("v1.2.0"));
229 assert!(validate_name("app-x86_64.tar.gz")); 294 assert!(validate_name("app-x86_64.tar.gz"));
@@ -283,4 +348,43 @@ mod tests {
283 assert!(parse_ssh_remote("../relative/path").is_none()); 348 assert!(parse_ssh_remote("../relative/path").is_none());
284 assert!(parse_ssh_remote("file:///srv/git/repo.git").is_none()); 349 assert!(parse_ssh_remote("file:///srv/git/repo.git").is_none());
285 } 350 }
351
352 #[test]
353 fn parse_rejects_out_of_range_port() {
354 assert!(parse_ssh_remote("ssh://host:99999/x").is_none());
355 }
356
357 #[test]
358 fn parse_rejects_scp_style_with_empty_path() {
359 assert!(parse_ssh_remote("host:").is_none());
360 }
361
362 #[test]
363 fn parse_rejects_scp_style_with_empty_authority() {
364 assert!(parse_ssh_remote(":path").is_none());
365 }
366
367 #[test]
368 fn parse_rejects_ssh_url_without_path() {
369 assert!(parse_ssh_remote("ssh://host").is_none());
370 }
371
372 #[test]
373 fn rejects_remote_path_with_single_quote() {
374 let remote = parse_ssh_remote("ssh://git@example.com/evil'; rm -rf /.git").unwrap();
375 let err = validate_remote_path(&remote.path).unwrap_err();
376 assert!(matches!(err, Error::Cmd(ref m) if m.contains("quotes")));
377 }
378
379 #[test]
380 fn rejects_remote_path_with_double_quote() {
381 let remote = parse_ssh_remote("ssh://git@example.com/evil\"x").unwrap();
382 assert!(validate_remote_path(&remote.path).is_err());
383 }
384
385 #[test]
386 fn accepts_remote_path_without_quotes() {
387 let remote = parse_ssh_remote("ssh://git@example.com/myrepo.git").unwrap();
388 assert!(validate_remote_path(&remote.path).is_ok());
389 }
286 } 390 }
src/server/releases.rs
Old New
@@ -14,6 +14,12 @@
14 //! temp-file-then-rename, so a reader (e.g. an HTTP download) never 14 //! temp-file-then-rename, so a reader (e.g. an HTTP download) never
15 //! observes a partially-written or truncated file for either — only a 15 //! observes a partially-written or truncated file for either — only a
16 //! fully-old or fully-new version, whichever `finish()` happened to win. 16 //! fully-old or fully-new version, whichever `finish()` happened to win.
17 //!
18 //! Crash note: uploads stream into a hidden `.tmp` file inside the version
19 //! directory, removed on drop. If the process dies mid-upload that file
20 //! survives. It is never listed or downloadable (listing skips dotfiles),
21 //! but it does pin an otherwise-empty version directory into existence.
22 //! There is no reaper — such leftovers need manual cleanup.
17 23
18 use std::io::Write; 24 use std::io::Write;
19 use std::path::{Path, PathBuf}; 25 use std::path::{Path, PathBuf};
@@ -78,11 +84,24 @@ impl ReleaseUpload {
78 if !validate_name(filename) { 84 if !validate_name(filename) {
79 return Err(ReleaseError::InvalidName(filename.to_string())); 85 return Err(ReleaseError::InvalidName(filename.to_string()));
80 } 86 }
87 // `<name>.sha256` is this store's checksum companion namespace.
88 // Accepting an upload with that suffix would let a --force upload of
89 // `app.tar.gz.sha256` overwrite the real companion of `app.tar.gz`
90 // with arbitrary content.
91 if filename.ends_with(".sha256") {
92 return Err(ReleaseError::InvalidName(format!(
93 "{}: .sha256 names are reserved for checksums",
94 filename
95 )));
96 }
81 let version_dir = releases_dir.join(version); 97 let version_dir = releases_dir.join(version);
82 std::fs::create_dir_all(&version_dir)?; 98 std::fs::create_dir_all(&version_dir)?;
83 let dest = version_dir.join(filename); 99 let dest = version_dir.join(filename);
84 if dest.exists() && !force { 100 if dest.exists() && !force {
85 return Err(ReleaseError::AlreadyExists(format!("{}/{}", version, filename))); 101 return Err(ReleaseError::AlreadyExists(format!(
102 "{}/{}",
103 version, filename
104 )));
86 } 105 }
87 let temp = NamedTempFile::new_in(&version_dir)?; 106 let temp = NamedTempFile::new_in(&version_dir)?;
88 let sha_dest = version_dir.join(format!("{}.sha256", filename)); 107 let sha_dest = version_dir.join(format!("{}.sha256", filename));
@@ -215,7 +234,9 @@ pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError>
215 let read_dir = match std::fs::read_dir(releases_dir) { 234 let read_dir = match std::fs::read_dir(releases_dir) {
216 Ok(rd) => rd, 235 Ok(rd) => rd,
217 Err(e) if e.kind() == std::io::ErrorKind::NotFound => { 236 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
218 return Ok(ReleaseIndex { versions: Vec::new() }) 237 return Ok(ReleaseIndex {
238 versions: Vec::new(),
239 })
219 } 240 }
220 Err(e) => return Err(e.into()), 241 Err(e) => return Err(e.into()),
221 }; 242 };
@@ -243,13 +264,16 @@ pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError>
243 if name.starts_with('.') || name.ends_with(".sha256") || !file_entry.path().is_file() { 264 if name.starts_with('.') || name.ends_with(".sha256") || !file_entry.path().is_file() {
244 continue; 265 continue;
245 } 266 }
267 // Never advertise a name `artifact_path` would refuse to resolve:
268 // a listing entry that 404s on download is worse than no entry.
269 if !validate_name(&name) {
270 continue;
271 }
246 let size = file_entry.metadata()?.len(); 272 let size = file_entry.metadata()?.len();
247 let sha256 = std::fs::read_to_string( 273 let sha256 = std::fs::read_to_string(entry.path().join(format!("{}.sha256", name)))
248 entry.path().join(format!("{}.sha256", name)), 274 .ok()
249 ) 275 .and_then(|s| s.split_whitespace().next().map(|t| t.to_string()))
250 .ok() 276 .unwrap_or_default();
251 .and_then(|s| s.split_whitespace().next().map(|t| t.to_string()))
252 .unwrap_or_default();
253 files.push(ReleaseFile { name, size, sha256 }); 277 files.push(ReleaseFile { name, size, sha256 });
254 } 278 }
255 files.sort_by(|a, b| a.name.cmp(&b.name)); 279 files.sort_by(|a, b| a.name.cmp(&b.name));
@@ -259,7 +283,14 @@ pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError>
259 if files.is_empty() { 283 if files.is_empty() {
260 continue; 284 continue;
261 } 285 }
262 versions.push((mtime, ReleaseVersion { version, published, files })); 286 versions.push((
287 mtime,
288 ReleaseVersion {
289 version,
290 published,
291 files,
292 },
293 ));
263 } 294 }
264 295
265 versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.version.cmp(&a.1.version))); 296 versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.version.cmp(&a.1.version)));
@@ -332,13 +363,80 @@ mod tests {
332 use super::*; 363 use super::*;
333 use tempfile::TempDir; 364 use tempfile::TempDir;
334 365
335 fn upload(dir: &Path, version: &str, name: &str, content: &[u8], force: bool) -> Result<String, ReleaseError> { 366 fn upload(
367 dir: &Path,
368 version: &str,
369 name: &str,
370 content: &[u8],
371 force: bool,
372 ) -> Result<String, ReleaseError> {
336 let mut up = ReleaseUpload::begin(dir, version, name, force, DEFAULT_MAX_RELEASE_SIZE)?; 373 let mut up = ReleaseUpload::begin(dir, version, name, force, DEFAULT_MAX_RELEASE_SIZE)?;
337 up.write(content)?; 374 up.write(content)?;
338 up.finish() 375 up.finish()
339 } 376 }
340 377
341 #[test] 378 #[test]
379 fn begin_rejects_sha256_filename() {
380 // `.sha256` is our checksum companion namespace. Allowing an upload
381 // named `app.tar.gz.sha256` would let a force-upload overwrite the
382 // real companion of `app.tar.gz` with attacker-chosen content.
383 let tmp = TempDir::new().unwrap();
384 // ReleaseUpload isn't Debug, so match rather than unwrap_err().
385 let err = match ReleaseUpload::begin(
386 tmp.path(),
387 "v1",
388 "app.tar.gz.sha256",
389 false,
390 DEFAULT_MAX_RELEASE_SIZE,
391 ) {
392 Ok(_) => panic!("expected a .sha256 filename to be rejected"),
393 Err(e) => e,
394 };
395 assert!(
396 matches!(err, ReleaseError::InvalidName(_)),
397 "expected InvalidName, got {:?}",
398 err
399 );
400 assert!(err.to_string().contains("reserved"), "got: {}", err);
401
402 // Even with --force, which is the dangerous direction.
403 assert!(ReleaseUpload::begin(
404 tmp.path(),
405 "v1",
406 "app.tar.gz.sha256",
407 true,
408 DEFAULT_MAX_RELEASE_SIZE
409 )
410 .is_err());
411 }
412
413 #[test]
414 fn list_skips_files_failing_validate_name() {
415 // A name the listing advertises must be one `artifact_path` will
416 // resolve, or the UI links to a guaranteed 404.
417 let tmp = TempDir::new().unwrap();
418 upload(tmp.path(), "v1", "good.tar.gz", b"ok", false).unwrap();
419 let version_dir = tmp.path().join("v1");
420 std::fs::write(version_dir.join("-leading-dash.tar.gz"), b"x").unwrap();
421 std::fs::write(version_dir.join("has space.tar.gz"), b"x").unwrap();
422
423 let index = list_releases(tmp.path()).unwrap();
424 let files: Vec<&str> = index.versions[0]
425 .files
426 .iter()
427 .map(|f| f.name.as_str())
428 .collect();
429 assert_eq!(files, vec!["good.tar.gz"]);
430 for name in &files {
431 assert!(
432 artifact_path(tmp.path(), "v1", name).is_ok(),
433 "listed name {} is not resolvable",
434 name
435 );
436 }
437 }
438
439 #[test]
342 fn upload_writes_file_and_checksum() { 440 fn upload_writes_file_and_checksum() {
343 let tmp = TempDir::new().unwrap(); 441 let tmp = TempDir::new().unwrap();
344 let sha = upload(tmp.path(), "v1.0.0", "app.tar.gz", b"hello", false).unwrap(); 442 let sha = upload(tmp.path(), "v1.0.0", "app.tar.gz", b"hello", false).unwrap();
@@ -406,9 +504,11 @@ mod tests {
406 std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(), 504 std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(),
407 b"two" 505 b"two"
408 ); 506 );
409 assert!(std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256")) 507 assert!(
410 .unwrap() 508 std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256"))
411 .starts_with(&sha_after)); 509 .unwrap()
510 .starts_with(&sha_after)
511 );
412 } 512 }
413 513
414 #[test] 514 #[test]
@@ -420,10 +520,12 @@ mod tests {
420 drop(up); 520 drop(up);
421 // no artifact, no stray temp files 521 // no artifact, no stray temp files
422 assert!(!tmp.path().join("v1").join("big.tar.gz").exists()); 522 assert!(!tmp.path().join("v1").join("big.tar.gz").exists());
423 let leftovers: Vec<_> = std::fs::read_dir(tmp.path().join("v1")) 523 let leftovers: Vec<_> = std::fs::read_dir(tmp.path().join("v1")).unwrap().collect();
424 .unwrap() 524 assert!(
425 .collect(); 525 leftovers.is_empty(),
426 assert!(leftovers.is_empty(), "temp files left behind: {:?}", leftovers); 526 "temp files left behind: {:?}",
527 leftovers
528 );
427 } 529 }
428 530
429 #[test] 531 #[test]
src/server/repos.rs
Old New
@@ -11,19 +11,14 @@ pub struct RepoEntry {
11 pub policy: RepoPolicy, 11 pub policy: RepoPolicy,
12 } 12 }
13 13
14 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 14 #[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
15 #[serde(rename_all = "lowercase")] 15 #[serde(rename_all = "lowercase")]
16 pub enum RepoVisibility { 16 pub enum RepoVisibility {
17 #[default]
17 Public, 18 Public,
18 Private, 19 Private,
19 } 20 }
20 21
21 impl Default for RepoVisibility {
22 fn default() -> Self {
23 Self::Public
24 }
25 }
26
27 #[derive(Debug, Clone, Deserialize)] 22 #[derive(Debug, Clone, Deserialize)]
28 pub struct RepoUiPolicy { 23 pub struct RepoUiPolicy {
29 #[serde(default = "default_true")] 24 #[serde(default = "default_true")]
src/server/ssh/session.rs
Old New
@@ -56,12 +56,20 @@ impl SshHandler {
56 } 56 }
57 } 57 }
58 58
59 /// Handle a release verb. The store calls here (`begin`, `list_releases`, 59 /// Handle a release verb.
60 /// `delete_release`) are synchronous filesystem I/O on the async runtime. 60 ///
61 /// That is an accepted tradeoff at this server's scale: the operations are 61 /// Every filesystem call in the release path is synchronous I/O performed
62 /// metadata-sized directory walks and renames, not the artifact streaming 62 /// on the async runtime: `begin`/`list_releases`/`delete_release` here,
63 /// itself (upload bytes arrive incrementally via `data()`). If release 63 /// the per-chunk `write` in `data()`, and — the largest one — `finish()`'s
64 /// counts or concurrency grow, move these onto `spawn_blocking`. 64 /// `sync_all` in `channel_eof`, which blocks until the artifact is durable
65 /// on disk. That is an accepted tradeoff at this server's scale (a handful
66 /// of connections, artifacts measured in megabytes); it does stall the
67 /// runtime thread, so if concurrency or artifact sizes grow these belong
68 /// on `spawn_blocking`.
69 ///
70 /// Only one upload may be in flight per connection: `active_upload` is a
71 /// single slot, and a second concurrent `upload` on a multiplexed channel
72 /// is rejected rather than allowed to displace the first.
65 fn handle_release_command( 73 fn handle_release_command(
66 &mut self, 74 &mut self,
67 channel: ChannelId, 75 channel: ChannelId,
@@ -107,24 +115,39 @@ impl SshHandler {
107 filename, 115 filename,
108 force, 116 force,
109 .. 117 ..
110 } => match crate::releases::ReleaseUpload::begin( 118 } => {
111 &dir, 119 // One upload slot per connection: a second one would silently
112 &version, 120 // displace the first, discarding its temp file and leaving its
113 &filename, 121 // channel waiting for a reply that never comes.
114 force, 122 if self.active_upload.is_some() {
115 self.config.max_release_size, 123 warn!("Rejected release upload: another upload is already in progress");
116 ) { 124 reply_and_close(
117 Ok(upload) => { 125 session,
118 self.active_upload = Some(UploadSession {
119 channel, 126 channel,
120 state: UploadState::Active(Box::new(upload)), 127 "error: another upload is already in progress on this connection\n",
121 }); 128 1,
122 // Reply comes on channel EOF, once all bytes have arrived. 129 );
130 return;
123 } 131 }
124 Err(e) => { 132 match crate::releases::ReleaseUpload::begin(
125 reply_and_close(session, channel, &format!("error: {}\n", e), 1); 133 &dir,
134 &version,
135 &filename,
136 force,
137 self.config.max_release_size,
138 ) {
139 Ok(upload) => {
140 self.active_upload = Some(UploadSession {
141 channel,
142 state: UploadState::Active(Box::new(upload)),
143 });
144 // Reply comes on channel EOF, once all bytes arrive.
145 }
146 Err(e) => {
147 reply_and_close(session, channel, &format!("error: {}\n", e), 1);
148 }
126 } 149 }
127 }, 150 }
128 ReleaseCmd::List { .. } => match crate::releases::list_releases(&dir) { 151 ReleaseCmd::List { .. } => match crate::releases::list_releases(&dir) {
129 Ok(index) => match serde_json::to_string_pretty(&index) { 152 Ok(index) => match serde_json::to_string_pretty(&index) {
130 Ok(json) => reply_and_close(session, channel, &format!("{}\n", json), 0), 153 Ok(json) => reply_and_close(session, channel, &format!("{}\n", json), 0),
@@ -565,7 +588,7 @@ impl Handler for SshHandler {
565 &mut self, 588 &mut self,
566 channel: ChannelId, 589 channel: ChannelId,
567 data: &[u8], 590 data: &[u8],
568 _session: &mut Session, 591 session: &mut Session,
569 ) -> Result<(), Self::Error> { 592 ) -> Result<(), Self::Error> {
570 if let Some(upload) = self.active_upload.as_mut() { 593 if let Some(upload) = self.active_upload.as_mut() {
571 if upload.channel == channel { 594 if upload.channel == channel {
@@ -579,6 +602,16 @@ impl Handler for SshHandler {
579 }, 602 },
580 failed => failed, 603 failed => failed,
581 }; 604 };
605 // Abort as soon as the write fails (e.g. the size cap) rather
606 // than absorbing the rest of the stream and only complaining
607 // at EOF: tell the client now and close the channel. The
608 // Failed latch stays as a fallback for chunks already in
609 // flight, which then land here with no active upload.
610 if let UploadState::Failed(msg) = &upload.state {
611 let msg = format!("error: {}\n", msg);
612 self.active_upload = None;
613 reply_and_close(session, channel, &msg, 1);
614 }
582 return Ok(()); 615 return Ok(());
583 } 616 }
584 } 617 }
tests/release_cli_test.rs
Old New
@@ -28,7 +28,13 @@ fn publish_list_delete_roundtrip() {
28 28
29 let publish = release_cmd( 29 let publish = release_cmd(
30 &harness, 30 &harness,
31 &["publish", "v1.0.0", tarball.to_str().unwrap(), "--remote", "srv"], 31 &[
32 "publish",
33 "v1.0.0",
34 tarball.to_str().unwrap(),
35 "--remote",
36 "srv",
37 ],
32 ); 38 );
33 assert!( 39 assert!(
34 publish.status.success(), 40 publish.status.success(),
@@ -50,9 +56,10 @@ fn publish_list_delete_roundtrip() {
50 56
51 let delete = release_cmd(&harness, &["delete", "v1.0.0", "--remote", "srv"]); 57 let delete = release_cmd(&harness, &["delete", "v1.0.0", "--remote", "srv"]);
52 assert!(delete.status.success()); 58 assert!(delete.status.success());
53 let after: serde_json::Value = 59 let after: serde_json::Value = serde_json::from_slice(
54 serde_json::from_slice(&release_cmd(&harness, &["list", "--json", "--remote", "srv"]).stdout) 60 &release_cmd(&harness, &["list", "--json", "--remote", "srv"]).stdout,
55 .unwrap(); 61 )
62 .unwrap();
56 assert_eq!(after["versions"].as_array().unwrap().len(), 0); 63 assert_eq!(after["versions"].as_array().unwrap().len(), 0);
57 } 64 }
58 65
@@ -63,13 +70,34 @@ fn duplicate_publish_needs_force_flag() {
63 std::fs::write(&tarball, b"one").unwrap(); 70 std::fs::write(&tarball, b"one").unwrap();
64 let path = tarball.to_str().unwrap(); 71 let path = tarball.to_str().unwrap();
65 72
66 assert!(release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]) 73 assert!(
67 .status 74 release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"])
68 .success()); 75 .status
76 .success()
77 );
69 78
70 let dup = release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]); 79 let dup = release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]);
71 assert!(!dup.status.success()); 80 assert!(!dup.status.success());
72 assert!(String::from_utf8_lossy(&dup.stderr).contains("already exists")); 81 let stderr = String::from_utf8_lossy(&dup.stderr);
82 assert!(stderr.contains("already exists"), "stderr: {}", stderr);
83 // The server's "error: ..." reply must not be doubled up, and ssh's own
84 // host-key banner must not be glued into the message (the reproduced bug).
85 assert_eq!(
86 stderr.matches("error:").count(),
87 1,
88 "expected exactly one 'error:' in stderr: {}",
89 stderr
90 );
91 assert!(
92 stderr.trim_start().starts_with("error:"),
93 "stderr should start with 'error:': {}",
94 stderr
95 );
96 assert!(
97 !stderr.contains("Warning: Permanently added"),
98 "ssh host-key banner leaked into error message: {}",
99 stderr
100 );
73 101
74 std::fs::write(&tarball, b"two").unwrap(); 102 std::fs::write(&tarball, b"two").unwrap();
75 let forced = release_cmd( 103 let forced = release_cmd(
@@ -88,7 +116,13 @@ fn non_ssh_remote_is_a_clear_error() {
88 // "origin" is a local filesystem path in the harness 116 // "origin" is a local filesystem path in the harness
89 let output = release_cmd( 117 let output = release_cmd(
90 &harness, 118 &harness,
91 &["publish", "v1", tarball.to_str().unwrap(), "--remote", "origin"], 119 &[
120 "publish",
121 "v1",
122 tarball.to_str().unwrap(),
123 "--remote",
124 "origin",
125 ],
92 ); 126 );
93 assert!(!output.status.success()); 127 assert!(!output.status.success());
94 assert!(String::from_utf8_lossy(&output.stderr).contains("not an SSH remote")); 128 assert!(String::from_utf8_lossy(&output.stderr).contains("not an SSH remote"));
@@ -102,8 +136,68 @@ fn invalid_version_rejected_client_side() {
102 136
103 let output = release_cmd( 137 let output = release_cmd(
104 &harness, 138 &harness,
105 &["publish", "../evil", tarball.to_str().unwrap(), "--remote", "srv"], 139 &[
140 "publish",
141 "../evil",
142 tarball.to_str().unwrap(),
143 "--remote",
144 "srv",
145 ],
106 ); 146 );
107 assert!(!output.status.success()); 147 assert!(!output.status.success());
108 assert!(String::from_utf8_lossy(&output.stderr).contains("invalid version")); 148 assert!(String::from_utf8_lossy(&output.stderr).contains("invalid version"));
149
150 // delete() must validate client-side too, mirroring publish() — no network
151 // round trip needed to reject an obviously-bad version name.
152 let delete = release_cmd(&harness, &["delete", "../evil", "--remote", "srv"]);
153 assert!(!delete.status.success());
154 assert!(String::from_utf8_lossy(&delete.stderr).contains("invalid version"));
155 }
156
157 /// If one file in a multi-file publish fails (e.g. a duplicate rejected
158 /// without --force), earlier successes are still reported, the command exits
159 /// non-zero, and the failing file's error is reported too.
160 #[test]
161 fn multi_file_publish_reports_partial_failure() {
162 let harness = setup("cli-partial");
163 let file_a = harness.work_repo().dir.path().join("file_a.tar.gz");
164 let file_b = harness.work_repo().dir.path().join("file_b.tar.gz");
165 std::fs::write(&file_a, b"a-bytes").unwrap();
166 std::fs::write(&file_b, b"b-bytes").unwrap();
167
168 // Publish file_a first, so a later attempt to republish it without
169 // --force is rejected as a duplicate.
170 assert!(release_cmd(
171 &harness,
172 &["publish", "v1", file_a.to_str().unwrap(), "--remote", "srv"]
173 )
174 .status
175 .success());
176
177 // Now publish [file_b, file_a]: file_b is new (succeeds), file_a is a
178 // duplicate (fails).
179 let output = release_cmd(
180 &harness,
181 &[
182 "publish",
183 "v1",
184 file_b.to_str().unwrap(),
185 file_a.to_str().unwrap(),
186 "--remote",
187 "srv",
188 ],
189 );
190 assert!(!output.status.success());
191 let stdout = String::from_utf8_lossy(&output.stdout);
192 assert!(
193 stdout.contains("Published v1/file_b.tar.gz"),
194 "stdout: {}",
195 stdout
196 );
197 let stderr = String::from_utf8_lossy(&output.stderr);
198 assert!(
199 stderr.contains("file_a.tar.gz") && stderr.contains("already exists"),
200 "stderr: {}",
201 stderr
202 );
109 } 203 }
tests/release_server_test.rs
Old New
@@ -1,5 +1,7 @@
1 mod common; 1 mod common;
2 2
3 use std::process::Output;
4
3 use common::ServerHarness; 5 use common::ServerHarness;
4 6
5 #[test] 7 #[test]
@@ -31,8 +33,6 @@ fn ssh_client_interop_rejects_unknown_command() {
31 ); 33 );
32 } 34 }
33 35
34 use std::process::Output;
35
36 fn stdout(output: &Output) -> String { 36 fn stdout(output: &Output) -> String {
37 String::from_utf8_lossy(&output.stdout).to_string() 37 String::from_utf8_lossy(&output.stdout).to_string()
38 } 38 }
@@ -320,7 +320,9 @@ fn http_releases_page_and_download() {
320 let page = harness.get_ok("/release-http/releases"); 320 let page = harness.get_ok("/release-http/releases");
321 assert!(page.body.contains("v2.0.0")); 321 assert!(page.body.contains("v2.0.0"));
322 assert!(page.body.contains("app.tar.gz")); 322 assert!(page.body.contains("app.tar.gz"));
323 assert!(page.body.contains("/release-http/releases/v2.0.0/app.tar.gz")); 323 assert!(page
324 .body
325 .contains("/release-http/releases/v2.0.0/app.tar.gz"));
324 326
325 let (head, body) = harness.get_bytes("/release-http/releases/v2.0.0/app.tar.gz"); 327 let (head, body) = harness.get_bytes("/release-http/releases/v2.0.0/app.tar.gz");
326 assert!(head.contains("200"), "download failed: {}", head); 328 assert!(head.contains("200"), "download failed: {}", head);
@@ -331,8 +333,7 @@ fn http_releases_page_and_download() {
331 assert_eq!(body, content); 333 assert_eq!(body, content);
332 334
333 // checksum companion is downloadable as text 335 // checksum companion is downloadable as text
334 let (sha_head, sha_body) = 336 let (sha_head, sha_body) = harness.get_bytes("/release-http/releases/v2.0.0/app.tar.gz.sha256");
335 harness.get_bytes("/release-http/releases/v2.0.0/app.tar.gz.sha256");
336 assert!(sha_head.contains("200")); 337 assert!(sha_head.contains("200"));
337 assert!(String::from_utf8(sha_body).unwrap().contains("app.tar.gz")); 338 assert!(String::from_utf8(sha_body).unwrap().contains("app.tar.gz"));
338 } 339 }