10ac0c7f
Make the maximum release size configurable
a73x 2026-08-08 14:43
Commit message
docs/superpowers/plans/2026-08-08-release-packages.md
| Old | New | ||
|---|---|---|---|
| @@ -121,6 +121,17 @@ git commit -m "Add shared release name validation" | |||
| 121 | 121 | ||
| 122 | ### Task 2: Server release store | 122 | ### Task 2: Server release store |
| 123 | 123 | ||
| 124 | **Execution deviations:** | ||
| 125 | - `list_releases`: fixed a type-inference conflict — the `NotFound` early return forced `versions: Vec<ReleaseVersion>` while the loop later pushed `(SystemTime, ReleaseVersion)` tuples; gave `versions` an explicit tuple type and returned `Vec::new()` directly from the early-return branch. | ||
| 126 | - `finish()`: dropped the no-op `self.temp.flush()` and call `self.temp.as_file().sync_all()?` before persisting, for durability across crashes. | ||
| 127 | - `finish()`: in the force branch only, delete the existing `.sha256` companion before persisting (ignoring `NotFound`), then persist, then write the new companion, so a crash window degrades to "companion missing" rather than "companion wrong"; the no-force branch does not pre-delete, so a rejected duplicate upload leaves the existing artifact's valid companion untouched. | ||
| 128 | - `finish()`: map `persist_noclobber`'s `AlreadyExists` io error to `ReleaseError::AlreadyExists` (was surfacing a raw "os error 17"); `ReleaseUpload` now stores `version` to build the message. | ||
| 129 | - `finish()`: chmod the persisted artifact to `0o644` before persisting (unix-only), so it doesn't keep `NamedTempFile`'s `0600`. | ||
| 130 | - `list_releases`: skip versions whose `files` list is empty, since `begin()` creates the version dir before any bytes arrive and an aborted upload can otherwise phantom-list a zero-file version. | ||
| 131 | - `list_releases`: descending-mtime sort now breaks ties by version name descending, so equal-mtime versions still order lexically-newest-first. | ||
| 132 | - Added `pub fn artifact_path(releases_dir, version, filename) -> Result<PathBuf, ReleaseError>` (validates both segments, strips a single trailing `.sha256` before validating, requires `is_file()`) — **Task 7's HTTP download route must use this instead of hand-joining paths.** | ||
| 133 | - Added a concurrency note to the module doc comment: concurrent force-uploads to the same version/filename aren't serialized; single-writer deployments (current design) are unaffected. | ||
| 134 | |||
| 124 | Filesystem store: atomic uploads with SHA-256, listing, deletion. Lives in the server bin crate. | 135 | Filesystem store: atomic uploads with SHA-256, listing, deletion. Lives in the server bin crate. |
| 125 | 136 | ||
| 126 | **Files:** | 137 | **Files:** |
src/server/config.rs
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,8 @@ pub struct ServerConfig { | |||
| 13 | pub authorized_keys: PathBuf, | 13 | pub authorized_keys: PathBuf, |
| 14 | #[serde(default = "default_site_title")] | 14 | #[serde(default = "default_site_title")] |
| 15 | pub site_title: String, | 15 | pub site_title: String, |
| 16 | #[serde(default = "default_max_release_size")] | ||
| 17 | pub max_release_size: u64, | ||
| 16 | } | 18 | } |
| 17 | 19 | ||
| 18 | fn default_http_bind() -> SocketAddr { | 20 | fn default_http_bind() -> SocketAddr { |
| @@ -27,6 +29,10 @@ fn default_site_title() -> String { | |||
| 27 | "git-collab".to_string() | 29 | "git-collab".to_string() |
| 28 | } | 30 | } |
| 29 | 31 | ||
| 32 | fn default_max_release_size() -> u64 { | ||
| 33 | crate::releases::DEFAULT_MAX_RELEASE_SIZE | ||
| 34 | } | ||
| 35 | |||
| 30 | impl ServerConfig { | 36 | impl ServerConfig { |
| 31 | pub fn from_toml(content: &str) -> Result<Self, toml_edit::de::Error> { | 37 | pub fn from_toml(content: &str) -> Result<Self, toml_edit::de::Error> { |
| 32 | toml_edit::de::from_str(content) | 38 | toml_edit::de::from_str(content) |
| @@ -85,6 +91,7 @@ authorized_keys = "/keys" | |||
| 85 | "0.0.0.0:2222".parse::<SocketAddr>().unwrap() | 91 | "0.0.0.0:2222".parse::<SocketAddr>().unwrap() |
| 86 | ); | 92 | ); |
| 87 | assert_eq!(config.site_title, "git-collab"); | 93 | assert_eq!(config.site_title, "git-collab"); |
| 94 | assert_eq!(config.max_release_size, 1024 * 1024 * 1024); | ||
| 88 | } | 95 | } |
| 89 | 96 | ||
| 90 | #[test] | 97 | #[test] |
| @@ -94,4 +101,15 @@ authorized_keys = "/keys" | |||
| 94 | "#; | 101 | "#; |
| 95 | assert!(ServerConfig::from_toml(toml).is_err()); | 102 | assert!(ServerConfig::from_toml(toml).is_err()); |
| 96 | } | 103 | } |
| 104 | |||
| 105 | #[test] | ||
| 106 | fn parse_max_release_size() { | ||
| 107 | let toml = r#" | ||
| 108 | repos_dir = "/srv/git" | ||
| 109 | authorized_keys = "/keys" | ||
| 110 | max_release_size = 1024 | ||
| 111 | "#; | ||
| 112 | let config = ServerConfig::from_toml(toml).unwrap(); | ||
| 113 | assert_eq!(config.max_release_size, 1024); | ||
| 114 | } | ||
| 97 | } | 115 | } |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -67,6 +67,7 @@ async fn main() { | |||
| 67 | let ssh_config = ssh::session::SshServerConfig { | 67 | let ssh_config = ssh::session::SshServerConfig { |
| 68 | repos_dir: config.repos_dir.clone(), | 68 | repos_dir: config.repos_dir.clone(), |
| 69 | authorized_keys_path: config.authorized_keys.clone(), | 69 | authorized_keys_path: config.authorized_keys.clone(), |
| 70 | max_release_size: config.max_release_size, | ||
| 70 | }; | 71 | }; |
| 71 | 72 | ||
| 72 | let http_bind = config.http_bind; | 73 | let http_bind = config.http_bind; |
src/server/releases.rs
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,13 @@ | |||
| 3 | //! Layout: `<releases_dir>/<version>/<filename>` plus `<filename>.sha256` | 3 | //! Layout: `<releases_dir>/<version>/<filename>` plus `<filename>.sha256` |
| 4 | //! (sha256sum-compatible: "<hex> <filename>\n"). No manifest — the | 4 | //! (sha256sum-compatible: "<hex> <filename>\n"). No manifest — the |
| 5 | //! filesystem is the index. | 5 | //! filesystem is the index. |
| 6 | //! | ||
| 7 | //! Concurrency note: concurrent SSH sessions are not serialized against | ||
| 8 | //! each other. A single session handles its uploads one at a time, but two | ||
| 9 | //! separate connections can force-upload the same version/filename at the | ||
| 10 | //! same time; the last `finish()` to persist wins and the `.sha256` | ||
| 11 | //! companion may transiently mismatch between the two racing writers. This | ||
| 12 | //! case is accepted and unguarded — there is no locking across sessions. | ||
| 6 | 13 | ||
| 7 | use std::io::Write; | 14 | use std::io::Write; |
| 8 | use std::path::{Path, PathBuf}; | 15 | use std::path::{Path, PathBuf}; |
| @@ -48,6 +55,7 @@ pub struct ReleaseUpload { | |||
| 48 | max_size: u64, | 55 | max_size: u64, |
| 49 | dest: PathBuf, | 56 | dest: PathBuf, |
| 50 | sha_dest: PathBuf, | 57 | sha_dest: PathBuf, |
| 58 | version: String, | ||
| 51 | filename: String, | 59 | filename: String, |
| 52 | force: bool, | 60 | force: bool, |
| 53 | } | 61 | } |
| @@ -81,6 +89,7 @@ impl ReleaseUpload { | |||
| 81 | max_size, | 89 | max_size, |
| 82 | dest, | 90 | dest, |
| 83 | sha_dest, | 91 | sha_dest, |
| 92 | version: version.to_string(), | ||
| 84 | filename: filename.to_string(), | 93 | filename: filename.to_string(), |
| 85 | force, | 94 | force, |
| 86 | }) | 95 | }) |
| @@ -97,20 +106,62 @@ impl ReleaseUpload { | |||
| 97 | } | 106 | } |
| 98 | 107 | ||
| 99 | /// Finalize: atomic rename + write `.sha256`. Returns the hex digest. | 108 | /// Finalize: atomic rename + write `.sha256`. Returns the hex digest. |
| 100 | pub fn finish(mut self) -> Result<String, ReleaseError> { | 109 | pub fn finish(self) -> Result<String, ReleaseError> { |
| 101 | self.temp.flush()?; | 110 | // Durability: File::flush() is a no-op, so force the bytes to disk |
| 111 | // before we rename into place. | ||
| 112 | self.temp.as_file().sync_all()?; | ||
| 113 | |||
| 114 | #[cfg(unix)] | ||
| 115 | { | ||
| 116 | use std::os::unix::fs::PermissionsExt; | ||
| 117 | self.temp | ||
| 118 | .as_file() | ||
| 119 | .set_permissions(std::fs::Permissions::from_mode(0o644))?; | ||
| 120 | } | ||
| 121 | |||
| 102 | let digest = self.hasher.finalize(); | 122 | let digest = self.hasher.finalize(); |
| 103 | let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect(); | 123 | let hex = format!("{:x}", digest); |
| 124 | |||
| 104 | if self.force { | 125 | if self.force { |
| 126 | // The force path is the only one that can replace an existing | ||
| 127 | // artifact, so it's the only one where a stale companion could | ||
| 128 | // end up lying about new bytes. Delete it before persisting: a | ||
| 129 | // crash (or error) between persist and the companion write then | ||
| 130 | // degrades to "companion missing", never "companion wrong". In | ||
| 131 | // the no-force path below, persist_noclobber either fails (dest | ||
| 132 | // untouched, so the existing companion must survive) or | ||
| 133 | // succeeds (dest didn't exist, so the post-persist companion | ||
| 134 | // write below overwrites any orphaned stale companion anyway) — | ||
| 135 | // no pre-delete needed, and pre-deleting there would destroy a | ||
| 136 | // valid companion on a merely-rejected duplicate upload. | ||
| 137 | if let Err(e) = std::fs::remove_file(&self.sha_dest) { | ||
| 138 | if e.kind() != std::io::ErrorKind::NotFound { | ||
| 139 | return Err(e.into()); | ||
| 140 | } | ||
| 141 | } | ||
| 105 | self.temp | 142 | self.temp |
| 106 | .persist(&self.dest) | 143 | .persist(&self.dest) |
| 107 | .map_err(|e| ReleaseError::Io(e.error))?; | 144 | .map_err(|e| ReleaseError::Io(e.error))?; |
| 108 | } else { | 145 | } else { |
| 109 | self.temp | 146 | self.temp.persist_noclobber(&self.dest).map_err(|e| { |
| 110 | .persist_noclobber(&self.dest) | 147 | if e.error.kind() == std::io::ErrorKind::AlreadyExists { |
| 111 | .map_err(|e| ReleaseError::Io(e.error))?; | 148 | ReleaseError::AlreadyExists(format!("{}/{}", self.version, self.filename)) |
| 149 | } else { | ||
| 150 | ReleaseError::Io(e.error) | ||
| 151 | } | ||
| 152 | })?; | ||
| 112 | } | 153 | } |
| 113 | std::fs::write(&self.sha_dest, format!("{} {}\n", hex, self.filename))?; | 154 | std::fs::write(&self.sha_dest, format!("{} {}\n", hex, self.filename))?; |
| 155 | // std::fs::write() creates the file honoring the process umask, so | ||
| 156 | // under a restrictive umask (e.g. 077) the companion could end up | ||
| 157 | // 0600 while the artifact above is explicitly forced to 0644. | ||
| 158 | // Force an explicit mode so the two are consistent regardless of | ||
| 159 | // umask. | ||
| 160 | #[cfg(unix)] | ||
| 161 | { | ||
| 162 | use std::os::unix::fs::PermissionsExt; | ||
| 163 | std::fs::set_permissions(&self.sha_dest, std::fs::Permissions::from_mode(0o644))?; | ||
| 164 | } | ||
| 114 | Ok(hex) | 165 | Ok(hex) |
| 115 | } | 166 | } |
| 116 | } | 167 | } |
| @@ -180,10 +231,16 @@ pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError> | |||
| 180 | files.push(ReleaseFile { name, size, sha256 }); | 231 | files.push(ReleaseFile { name, size, sha256 }); |
| 181 | } | 232 | } |
| 182 | files.sort_by(|a, b| a.name.cmp(&b.name)); | 233 | files.sort_by(|a, b| a.name.cmp(&b.name)); |
| 234 | // begin() creates the version directory before any bytes have | ||
| 235 | // arrived, so an aborted upload can leave an empty version dir. | ||
| 236 | // Don't list phantom versions with zero files. | ||
| 237 | if files.is_empty() { | ||
| 238 | continue; | ||
| 239 | } | ||
| 183 | versions.push((mtime, ReleaseVersion { version, published, files })); | 240 | versions.push((mtime, ReleaseVersion { version, published, files })); |
| 184 | } | 241 | } |
| 185 | 242 | ||
| 186 | versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.version.cmp(&b.1.version))); | 243 | versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.version.cmp(&a.1.version))); |
| 187 | Ok(ReleaseIndex { | 244 | Ok(ReleaseIndex { |
| 188 | versions: versions.into_iter().map(|(_, v)| v).collect(), | 245 | versions: versions.into_iter().map(|(_, v)| v).collect(), |
| 189 | }) | 246 | }) |
| @@ -226,6 +283,28 @@ pub fn delete_release( | |||
| 226 | Ok(()) | 283 | Ok(()) |
| 227 | } | 284 | } |
| 228 | 285 | ||
| 286 | /// Resolve an artifact (or `.sha256` companion) path safely. Validates both | ||
| 287 | /// segments; the filename may carry a single ".sha256" suffix. Returns | ||
| 288 | /// `NotFound` if the file does not exist. | ||
| 289 | pub fn artifact_path( | ||
| 290 | releases_dir: &Path, | ||
| 291 | version: &str, | ||
| 292 | filename: &str, | ||
| 293 | ) -> Result<PathBuf, ReleaseError> { | ||
| 294 | if !validate_name(version) { | ||
| 295 | return Err(ReleaseError::InvalidName(version.to_string())); | ||
| 296 | } | ||
| 297 | let base_name = filename.strip_suffix(".sha256").unwrap_or(filename); | ||
| 298 | if !validate_name(base_name) { | ||
| 299 | return Err(ReleaseError::InvalidName(filename.to_string())); | ||
| 300 | } | ||
| 301 | let path = releases_dir.join(version).join(filename); | ||
| 302 | if !path.is_file() { | ||
| 303 | return Err(ReleaseError::NotFound(format!("{}/{}", version, filename))); | ||
| 304 | } | ||
| 305 | Ok(path) | ||
| 306 | } | ||
| 307 | |||
| 229 | #[cfg(test)] | 308 | #[cfg(test)] |
| 230 | mod tests { | 309 | mod tests { |
| 231 | use super::*; | 310 | use super::*; |
| @@ -255,6 +334,22 @@ mod tests { | |||
| 255 | ); | 334 | ); |
| 256 | } | 335 | } |
| 257 | 336 | ||
| 337 | #[cfg(unix)] | ||
| 338 | #[test] | ||
| 339 | fn companion_mode_is_explicit_0644() { | ||
| 340 | use std::os::unix::fs::PermissionsExt; | ||
| 341 | |||
| 342 | let tmp = TempDir::new().unwrap(); | ||
| 343 | upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap(); | ||
| 344 | |||
| 345 | // finish() sets an explicit mode on the companion (rather than | ||
| 346 | // relying on std::fs::write()'s umask-dependent default), so this | ||
| 347 | // holds regardless of the process umask. | ||
| 348 | let sha_file = tmp.path().join("v1").join("a.tar.gz.sha256"); | ||
| 349 | let mode = std::fs::metadata(&sha_file).unwrap().permissions().mode(); | ||
| 350 | assert_eq!(mode & 0o777, 0o644); | ||
| 351 | } | ||
| 352 | |||
| 258 | #[test] | 353 | #[test] |
| 259 | fn duplicate_upload_rejected_without_force() { | 354 | fn duplicate_upload_rejected_without_force() { |
| 260 | let tmp = TempDir::new().unwrap(); | 355 | let tmp = TempDir::new().unwrap(); |
| @@ -269,17 +364,29 @@ mod tests { | |||
| 269 | } | 364 | } |
| 270 | 365 | ||
| 271 | #[test] | 366 | #[test] |
| 367 | fn rejected_duplicate_keeps_existing_companion() { | ||
| 368 | let tmp = TempDir::new().unwrap(); | ||
| 369 | let original_sha = upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap(); | ||
| 370 | let err = upload(tmp.path(), "v1", "a.tar.gz", b"two", false).unwrap_err(); | ||
| 371 | assert!(matches!(err, ReleaseError::AlreadyExists(_))); | ||
| 372 | |||
| 373 | let companion = std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256")) | ||
| 374 | .expect("companion must still exist after a rejected duplicate upload"); | ||
| 375 | assert_eq!(companion, format!("{} a.tar.gz\n", original_sha)); | ||
| 376 | } | ||
| 377 | |||
| 378 | #[test] | ||
| 272 | fn force_replaces_file_and_checksum() { | 379 | fn force_replaces_file_and_checksum() { |
| 273 | let tmp = TempDir::new().unwrap(); | 380 | let tmp = TempDir::new().unwrap(); |
| 274 | upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap(); | 381 | upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap(); |
| 275 | let sha2 = upload(tmp.path(), "v1", "a.tar.gz", b"two", true).unwrap(); | 382 | let sha_after = upload(tmp.path(), "v1", "a.tar.gz", b"two", true).unwrap(); |
| 276 | assert_eq!( | 383 | assert_eq!( |
| 277 | std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(), | 384 | std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(), |
| 278 | b"two" | 385 | b"two" |
| 279 | ); | 386 | ); |
| 280 | assert!(std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256")) | 387 | assert!(std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256")) |
| 281 | .unwrap() | 388 | .unwrap() |
| 282 | .starts_with(&sha2)); | 389 | .starts_with(&sha_after)); |
| 283 | } | 390 | } |
| 284 | 391 | ||
| 285 | #[test] | 392 | #[test] |
| @@ -380,4 +487,116 @@ mod tests { | |||
| 380 | Err(ReleaseError::NotFound(_)) | 487 | Err(ReleaseError::NotFound(_)) |
| 381 | )); | 488 | )); |
| 382 | } | 489 | } |
| 490 | |||
| 491 | #[test] | ||
| 492 | fn multi_chunk_upload_streams_correctly() { | ||
| 493 | let tmp = TempDir::new().unwrap(); | ||
| 494 | let mut up = ReleaseUpload::begin( | ||
| 495 | tmp.path(), | ||
| 496 | "v1", | ||
| 497 | "app.tar.gz", | ||
| 498 | false, | ||
| 499 | DEFAULT_MAX_RELEASE_SIZE, | ||
| 500 | ) | ||
| 501 | .unwrap(); | ||
| 502 | up.write(b"hel").unwrap(); | ||
| 503 | up.write(b"lo, ").unwrap(); | ||
| 504 | up.write(b"world").unwrap(); | ||
| 505 | let sha = up.finish().unwrap(); | ||
| 506 | |||
| 507 | let expected = format!("{:x}", Sha256::digest(b"hello, world")); | ||
| 508 | assert_eq!(sha, expected); | ||
| 509 | assert_eq!( | ||
| 510 | std::fs::read(tmp.path().join("v1").join("app.tar.gz")).unwrap(), | ||
| 511 | b"hello, world" | ||
| 512 | ); | ||
| 513 | } | ||
| 514 | |||
| 515 | #[test] | ||
| 516 | fn exact_size_boundary_accepted() { | ||
| 517 | let tmp = TempDir::new().unwrap(); | ||
| 518 | let mut up = ReleaseUpload::begin(tmp.path(), "v1", "a.tar.gz", false, 5).unwrap(); | ||
| 519 | assert!(up.write(b"12345").is_ok()); | ||
| 520 | let sha = up.finish().unwrap(); | ||
| 521 | assert_eq!( | ||
| 522 | std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(), | ||
| 523 | b"12345" | ||
| 524 | ); | ||
| 525 | assert_eq!(sha.len(), 64); | ||
| 526 | } | ||
| 527 | |||
| 528 | #[test] | ||
| 529 | fn orphaned_companion_yields_empty_sha256() { | ||
| 530 | let tmp = TempDir::new().unwrap(); | ||
| 531 | upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap(); | ||
| 532 | std::fs::remove_file(tmp.path().join("v1").join("a.tar.gz.sha256")).unwrap(); | ||
| 533 | |||
| 534 | let index = list_releases(tmp.path()).unwrap(); | ||
| 535 | assert_eq!(index.versions[0].files[0].sha256, ""); | ||
| 536 | } | ||
| 537 | |||
| 538 | #[test] | ||
| 539 | fn empty_version_dir_not_listed() { | ||
| 540 | let tmp = TempDir::new().unwrap(); | ||
| 541 | std::fs::create_dir_all(tmp.path().join("v1-empty")).unwrap(); | ||
| 542 | upload(tmp.path(), "v2", "a.tar.gz", b"x", false).unwrap(); | ||
| 543 | |||
| 544 | let index = list_releases(tmp.path()).unwrap(); | ||
| 545 | assert_eq!(index.versions.len(), 1); | ||
| 546 | assert_eq!(index.versions[0].version, "v2"); | ||
| 547 | } | ||
| 548 | |||
| 549 | #[test] | ||
| 550 | fn concurrent_dest_creation_maps_to_already_exists() { | ||
| 551 | let tmp = TempDir::new().unwrap(); | ||
| 552 | let mut up = ReleaseUpload::begin( | ||
| 553 | tmp.path(), | ||
| 554 | "v1", | ||
| 555 | "a.tar.gz", | ||
| 556 | false, | ||
| 557 | DEFAULT_MAX_RELEASE_SIZE, | ||
| 558 | ) | ||
| 559 | .unwrap(); | ||
| 560 | up.write(b"race").unwrap(); | ||
| 561 | // Simulate another writer creating the dest file after begin() | ||
| 562 | // checked for its absence but before this upload persists. | ||
| 563 | std::fs::write(tmp.path().join("v1").join("a.tar.gz"), b"other").unwrap(); | ||
| 564 | |||
| 565 | let err = up.finish().unwrap_err(); | ||
| 566 | assert!(matches!(err, ReleaseError::AlreadyExists(_))); | ||
| 567 | } | ||
| 568 | |||
| 569 | #[test] | ||
| 570 | fn artifact_path_resolves_existing_and_companion() { | ||
| 571 | let tmp = TempDir::new().unwrap(); | ||
| 572 | upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap(); | ||
| 573 | |||
| 574 | let file = artifact_path(tmp.path(), "v1", "a.tar.gz").unwrap(); | ||
| 575 | assert_eq!(file, tmp.path().join("v1").join("a.tar.gz")); | ||
| 576 | |||
| 577 | let companion = artifact_path(tmp.path(), "v1", "a.tar.gz.sha256").unwrap(); | ||
| 578 | assert_eq!(companion, tmp.path().join("v1").join("a.tar.gz.sha256")); | ||
| 579 | } | ||
| 580 | |||
| 581 | #[test] | ||
| 582 | fn artifact_path_rejects_traversal() { | ||
| 583 | let tmp = TempDir::new().unwrap(); | ||
| 584 | assert!(matches!( | ||
| 585 | artifact_path(tmp.path(), "../v1", "a.tar.gz"), | ||
| 586 | Err(ReleaseError::InvalidName(_)) | ||
| 587 | )); | ||
| 588 | assert!(matches!( | ||
| 589 | artifact_path(tmp.path(), "v1", "../../a.tar.gz"), | ||
| 590 | Err(ReleaseError::InvalidName(_)) | ||
| 591 | )); | ||
| 592 | } | ||
| 593 | |||
| 594 | #[test] | ||
| 595 | fn artifact_path_missing_is_not_found() { | ||
| 596 | let tmp = TempDir::new().unwrap(); | ||
| 597 | assert!(matches!( | ||
| 598 | artifact_path(tmp.path(), "v1", "nope.tar.gz"), | ||
| 599 | Err(ReleaseError::NotFound(_)) | ||
| 600 | )); | ||
| 601 | } | ||
| 383 | } | 602 | } |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,7 @@ use super::auth::{is_authorized, load_authorized_keys}; | |||
| 17 | pub struct SshServerConfig { | 17 | pub struct SshServerConfig { |
| 18 | pub repos_dir: PathBuf, | 18 | pub repos_dir: PathBuf, |
| 19 | pub authorized_keys_path: PathBuf, | 19 | pub authorized_keys_path: PathBuf, |
| 20 | pub max_release_size: u64, | ||
| 20 | } | 21 | } |
| 21 | 22 | ||
| 22 | /// Per-connection SSH session handler. | 23 | /// Per-connection SSH session handler. |