9b324f39
Add the server-side release artifact store
a73x 2026-08-08 14:26
Commit message
Cargo.lock
| Old | New | ||
|---|---|---|---|
| @@ -1382,9 +1382,11 @@ dependencies = [ | |||
| 1382 | "russh-keys", | 1382 | "russh-keys", |
| 1383 | "serde", | 1383 | "serde", |
| 1384 | "serde_json", | 1384 | "serde_json", |
| 1385 | "sha2", | ||
| 1385 | "tempfile", | 1386 | "tempfile", |
| 1386 | "thiserror 2.0.18", | 1387 | "thiserror 2.0.18", |
| 1387 | "tokio", | 1388 | "tokio", |
| 1389 | "tokio-util", | ||
| 1388 | "toml_edit", | 1390 | "toml_edit", |
| 1389 | "tracing", | 1391 | "tracing", |
| 1390 | "tracing-subscriber", | 1392 | "tracing-subscriber", |
Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -35,6 +35,9 @@ toml_edit = { version = "0.22", features = ["serde"] } | |||
| 35 | tracing = "0.1" | 35 | tracing = "0.1" |
| 36 | tracing-subscriber = "0.3" | 36 | tracing-subscriber = "0.3" |
| 37 | async-trait = "0.1" | 37 | async-trait = "0.1" |
| 38 | sha2 = "0.10" | ||
| 39 | tempfile = "3" | ||
| 40 | tokio-util = { version = "0.7", features = ["io"] } | ||
| 38 | 41 | ||
| 39 | [build-dependencies] | 42 | [build-dependencies] |
| 40 | clap = { version = "4", features = ["derive"] } | 43 | clap = { version = "4", features = ["derive"] } |
| @@ -42,7 +45,6 @@ clap_complete = "4" | |||
| 42 | clap_mangen = "0.2" | 45 | clap_mangen = "0.2" |
| 43 | 46 | ||
| 44 | [dev-dependencies] | 47 | [dev-dependencies] |
| 45 | tempfile = "3" | ||
| 46 | proptest = "1" | 48 | proptest = "1" |
| 47 | criterion = { version = "0.5", features = ["html_reports"] } | 49 | criterion = { version = "0.5", features = ["html_reports"] } |
| 48 | 50 | ||
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -8,6 +8,7 @@ pub mod identity; | |||
| 8 | pub mod issue; | 8 | pub mod issue; |
| 9 | pub mod log; | 9 | pub mod log; |
| 10 | pub mod patch; | 10 | pub mod patch; |
| 11 | pub mod release; | ||
| 11 | pub mod signing; | 12 | pub mod signing; |
| 12 | pub mod state; | 13 | pub mod state; |
| 13 | pub mod status; | 14 | pub mod status; |
src/release.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,50 @@ | |||
| 1 | //! Client-side release commands and shared release name validation. | ||
| 2 | |||
| 3 | /// Maximum length in bytes for a release version or filename. | ||
| 4 | pub const MAX_NAME_LEN: usize = 128; | ||
| 5 | |||
| 6 | /// Validate a release version or filename: ASCII alphanumeric first char, | ||
| 7 | /// then alphanumeric plus `.`, `_`, `-`. No slashes, no leading dot, max 128 bytes. | ||
| 8 | pub fn validate_name(name: &str) -> bool { | ||
| 9 | if name.is_empty() || name.len() > MAX_NAME_LEN { | ||
| 10 | return false; | ||
| 11 | } | ||
| 12 | let mut chars = name.chars(); | ||
| 13 | let first = chars.next().unwrap(); | ||
| 14 | if !first.is_ascii_alphanumeric() { | ||
| 15 | return false; | ||
| 16 | } | ||
| 17 | chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') | ||
| 18 | } | ||
| 19 | |||
| 20 | #[cfg(test)] | ||
| 21 | mod tests { | ||
| 22 | use super::*; | ||
| 23 | |||
| 24 | #[test] | ||
| 25 | fn accepts_typical_names() { | ||
| 26 | assert!(validate_name("v1.2.0")); | ||
| 27 | assert!(validate_name("app-x86_64.tar.gz")); | ||
| 28 | assert!(validate_name("1.0")); | ||
| 29 | assert!(validate_name("RELEASE_2026")); | ||
| 30 | } | ||
| 31 | |||
| 32 | #[test] | ||
| 33 | fn rejects_traversal_and_separators() { | ||
| 34 | assert!(!validate_name("../etc")); | ||
| 35 | assert!(!validate_name("a/b")); | ||
| 36 | assert!(!validate_name("a\\b")); | ||
| 37 | assert!(!validate_name(".hidden")); | ||
| 38 | assert!(!validate_name("..")); | ||
| 39 | } | ||
| 40 | |||
| 41 | #[test] | ||
| 42 | fn rejects_empty_weird_and_overlong() { | ||
| 43 | assert!(!validate_name("")); | ||
| 44 | assert!(!validate_name("näme")); | ||
| 45 | assert!(!validate_name("a b")); | ||
| 46 | assert!(!validate_name("-leading-dash")); | ||
| 47 | assert!(!validate_name(&"a".repeat(129))); | ||
| 48 | assert!(validate_name(&"a".repeat(128))); | ||
| 49 | } | ||
| 50 | } | ||
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,7 @@ use tracing::info; | |||
| 5 | 5 | ||
| 6 | mod config; | 6 | mod config; |
| 7 | mod http; | 7 | mod http; |
| 8 | mod releases; | ||
| 8 | mod repos; | 9 | mod repos; |
| 9 | mod ssh; | 10 | mod ssh; |
| 10 | 11 | ||
src/server/releases.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,383 @@ | |||
| 1 | //! Filesystem-backed release artifact store. | ||
| 2 | //! | ||
| 3 | //! Layout: `<releases_dir>/<version>/<filename>` plus `<filename>.sha256` | ||
| 4 | //! (sha256sum-compatible: "<hex> <filename>\n"). No manifest — the | ||
| 5 | //! filesystem is the index. | ||
| 6 | |||
| 7 | use std::io::Write; | ||
| 8 | use std::path::{Path, PathBuf}; | ||
| 9 | |||
| 10 | use serde::Serialize; | ||
| 11 | use sha2::{Digest, Sha256}; | ||
| 12 | use tempfile::NamedTempFile; | ||
| 13 | |||
| 14 | use git_collab::release::validate_name; | ||
| 15 | |||
| 16 | pub const DEFAULT_MAX_RELEASE_SIZE: u64 = 1024 * 1024 * 1024; // 1 GiB | ||
| 17 | |||
| 18 | #[derive(Debug, thiserror::Error)] | ||
| 19 | pub enum ReleaseError { | ||
| 20 | #[error("invalid name: {0}")] | ||
| 21 | InvalidName(String), | ||
| 22 | #[error("{0} already exists (use --force to replace)")] | ||
| 23 | AlreadyExists(String), | ||
| 24 | #[error("file exceeds maximum release size ({0} bytes)")] | ||
| 25 | TooLarge(u64), | ||
| 26 | #[error("not found: {0}")] | ||
| 27 | NotFound(String), | ||
| 28 | #[error(transparent)] | ||
| 29 | Io(#[from] std::io::Error), | ||
| 30 | } | ||
| 31 | |||
| 32 | /// The releases directory for a repo entry: `<gitdir>/collab/releases`. | ||
| 33 | pub fn releases_dir(entry: &crate::repos::RepoEntry) -> PathBuf { | ||
| 34 | if entry.bare { | ||
| 35 | entry.path.join("collab").join("releases") | ||
| 36 | } else { | ||
| 37 | entry.path.join(".git").join("collab").join("releases") | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | /// An in-progress streamed upload. Bytes go to a temp file in the version | ||
| 42 | /// directory; `finish()` atomically renames into place and writes the | ||
| 43 | /// `.sha256` companion. Dropping without `finish()` cleans up the temp file. | ||
| 44 | pub struct ReleaseUpload { | ||
| 45 | temp: NamedTempFile, | ||
| 46 | hasher: Sha256, | ||
| 47 | written: u64, | ||
| 48 | max_size: u64, | ||
| 49 | dest: PathBuf, | ||
| 50 | sha_dest: PathBuf, | ||
| 51 | filename: String, | ||
| 52 | force: bool, | ||
| 53 | } | ||
| 54 | |||
| 55 | impl ReleaseUpload { | ||
| 56 | pub fn begin( | ||
| 57 | releases_dir: &Path, | ||
| 58 | version: &str, | ||
| 59 | filename: &str, | ||
| 60 | force: bool, | ||
| 61 | max_size: u64, | ||
| 62 | ) -> Result<Self, ReleaseError> { | ||
| 63 | if !validate_name(version) { | ||
| 64 | return Err(ReleaseError::InvalidName(version.to_string())); | ||
| 65 | } | ||
| 66 | if !validate_name(filename) { | ||
| 67 | return Err(ReleaseError::InvalidName(filename.to_string())); | ||
| 68 | } | ||
| 69 | let version_dir = releases_dir.join(version); | ||
| 70 | std::fs::create_dir_all(&version_dir)?; | ||
| 71 | let dest = version_dir.join(filename); | ||
| 72 | if dest.exists() && !force { | ||
| 73 | return Err(ReleaseError::AlreadyExists(format!("{}/{}", version, filename))); | ||
| 74 | } | ||
| 75 | let temp = NamedTempFile::new_in(&version_dir)?; | ||
| 76 | let sha_dest = version_dir.join(format!("{}.sha256", filename)); | ||
| 77 | Ok(Self { | ||
| 78 | temp, | ||
| 79 | hasher: Sha256::new(), | ||
| 80 | written: 0, | ||
| 81 | max_size, | ||
| 82 | dest, | ||
| 83 | sha_dest, | ||
| 84 | filename: filename.to_string(), | ||
| 85 | force, | ||
| 86 | }) | ||
| 87 | } | ||
| 88 | |||
| 89 | pub fn write(&mut self, chunk: &[u8]) -> Result<(), ReleaseError> { | ||
| 90 | self.written += chunk.len() as u64; | ||
| 91 | if self.written > self.max_size { | ||
| 92 | return Err(ReleaseError::TooLarge(self.max_size)); | ||
| 93 | } | ||
| 94 | self.hasher.update(chunk); | ||
| 95 | self.temp.write_all(chunk)?; | ||
| 96 | Ok(()) | ||
| 97 | } | ||
| 98 | |||
| 99 | /// Finalize: atomic rename + write `.sha256`. Returns the hex digest. | ||
| 100 | pub fn finish(mut self) -> Result<String, ReleaseError> { | ||
| 101 | self.temp.flush()?; | ||
| 102 | let digest = self.hasher.finalize(); | ||
| 103 | let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect(); | ||
| 104 | if self.force { | ||
| 105 | self.temp | ||
| 106 | .persist(&self.dest) | ||
| 107 | .map_err(|e| ReleaseError::Io(e.error))?; | ||
| 108 | } else { | ||
| 109 | self.temp | ||
| 110 | .persist_noclobber(&self.dest) | ||
| 111 | .map_err(|e| ReleaseError::Io(e.error))?; | ||
| 112 | } | ||
| 113 | std::fs::write(&self.sha_dest, format!("{} {}\n", hex, self.filename))?; | ||
| 114 | Ok(hex) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | #[derive(Debug, Serialize)] | ||
| 119 | pub struct ReleaseFile { | ||
| 120 | pub name: String, | ||
| 121 | pub size: u64, | ||
| 122 | pub sha256: String, | ||
| 123 | } | ||
| 124 | |||
| 125 | #[derive(Debug, Serialize)] | ||
| 126 | pub struct ReleaseVersion { | ||
| 127 | pub version: String, | ||
| 128 | pub published: String, | ||
| 129 | pub files: Vec<ReleaseFile>, | ||
| 130 | } | ||
| 131 | |||
| 132 | #[derive(Debug, Serialize)] | ||
| 133 | pub struct ReleaseIndex { | ||
| 134 | pub versions: Vec<ReleaseVersion>, | ||
| 135 | } | ||
| 136 | |||
| 137 | /// List versions newest-first (by directory mtime), files alphabetically. | ||
| 138 | /// `.sha256` companions and dotfiles are not listed as files. | ||
| 139 | /// A missing releases dir is an empty index. | ||
| 140 | pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError> { | ||
| 141 | let mut versions: Vec<(std::time::SystemTime, ReleaseVersion)> = Vec::new(); | ||
| 142 | let read_dir = match std::fs::read_dir(releases_dir) { | ||
| 143 | Ok(rd) => rd, | ||
| 144 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => { | ||
| 145 | return Ok(ReleaseIndex { versions: Vec::new() }) | ||
| 146 | } | ||
| 147 | Err(e) => return Err(e.into()), | ||
| 148 | }; | ||
| 149 | |||
| 150 | for entry in read_dir { | ||
| 151 | let entry = entry?; | ||
| 152 | if !entry.path().is_dir() { | ||
| 153 | continue; | ||
| 154 | } | ||
| 155 | let version = entry.file_name().to_string_lossy().to_string(); | ||
| 156 | if !validate_name(&version) { | ||
| 157 | continue; | ||
| 158 | } | ||
| 159 | let mtime = entry | ||
| 160 | .metadata()? | ||
| 161 | .modified() | ||
| 162 | .unwrap_or(std::time::SystemTime::UNIX_EPOCH); | ||
| 163 | let published = chrono::DateTime::<chrono::Utc>::from(mtime) | ||
| 164 | .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); | ||
| 165 | |||
| 166 | let mut files = Vec::new(); | ||
| 167 | for file_entry in std::fs::read_dir(entry.path())? { | ||
| 168 | let file_entry = file_entry?; | ||
| 169 | let name = file_entry.file_name().to_string_lossy().to_string(); | ||
| 170 | if name.starts_with('.') || name.ends_with(".sha256") || !file_entry.path().is_file() { | ||
| 171 | continue; | ||
| 172 | } | ||
| 173 | let size = file_entry.metadata()?.len(); | ||
| 174 | let sha256 = std::fs::read_to_string( | ||
| 175 | entry.path().join(format!("{}.sha256", name)), | ||
| 176 | ) | ||
| 177 | .ok() | ||
| 178 | .and_then(|s| s.split_whitespace().next().map(|t| t.to_string())) | ||
| 179 | .unwrap_or_default(); | ||
| 180 | files.push(ReleaseFile { name, size, sha256 }); | ||
| 181 | } | ||
| 182 | files.sort_by(|a, b| a.name.cmp(&b.name)); | ||
| 183 | versions.push((mtime, ReleaseVersion { version, published, files })); | ||
| 184 | } | ||
| 185 | |||
| 186 | versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.version.cmp(&b.1.version))); | ||
| 187 | Ok(ReleaseIndex { | ||
| 188 | versions: versions.into_iter().map(|(_, v)| v).collect(), | ||
| 189 | }) | ||
| 190 | } | ||
| 191 | |||
| 192 | /// Delete one file (and its `.sha256`) or, with `filename: None`, a whole | ||
| 193 | /// version. Removing the last file of a version removes the version dir. | ||
| 194 | pub fn delete_release( | ||
| 195 | releases_dir: &Path, | ||
| 196 | version: &str, | ||
| 197 | filename: Option<&str>, | ||
| 198 | ) -> Result<(), ReleaseError> { | ||
| 199 | if !validate_name(version) { | ||
| 200 | return Err(ReleaseError::InvalidName(version.to_string())); | ||
| 201 | } | ||
| 202 | let version_dir = releases_dir.join(version); | ||
| 203 | if !version_dir.is_dir() { | ||
| 204 | return Err(ReleaseError::NotFound(version.to_string())); | ||
| 205 | } | ||
| 206 | match filename { | ||
| 207 | None => { | ||
| 208 | std::fs::remove_dir_all(&version_dir)?; | ||
| 209 | } | ||
| 210 | Some(name) => { | ||
| 211 | if !validate_name(name) { | ||
| 212 | return Err(ReleaseError::InvalidName(name.to_string())); | ||
| 213 | } | ||
| 214 | let file = version_dir.join(name); | ||
| 215 | if !file.is_file() { | ||
| 216 | return Err(ReleaseError::NotFound(format!("{}/{}", version, name))); | ||
| 217 | } | ||
| 218 | std::fs::remove_file(&file)?; | ||
| 219 | let _ = std::fs::remove_file(version_dir.join(format!("{}.sha256", name))); | ||
| 220 | let is_empty = std::fs::read_dir(&version_dir)?.next().is_none(); | ||
| 221 | if is_empty { | ||
| 222 | std::fs::remove_dir(&version_dir)?; | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | Ok(()) | ||
| 227 | } | ||
| 228 | |||
| 229 | #[cfg(test)] | ||
| 230 | mod tests { | ||
| 231 | use super::*; | ||
| 232 | use tempfile::TempDir; | ||
| 233 | |||
| 234 | fn upload(dir: &Path, version: &str, name: &str, content: &[u8], force: bool) -> Result<String, ReleaseError> { | ||
| 235 | let mut up = ReleaseUpload::begin(dir, version, name, force, DEFAULT_MAX_RELEASE_SIZE)?; | ||
| 236 | up.write(content)?; | ||
| 237 | up.finish() | ||
| 238 | } | ||
| 239 | |||
| 240 | #[test] | ||
| 241 | fn upload_writes_file_and_checksum() { | ||
| 242 | let tmp = TempDir::new().unwrap(); | ||
| 243 | let sha = upload(tmp.path(), "v1.0.0", "app.tar.gz", b"hello", false).unwrap(); | ||
| 244 | // sha256 of "hello" | ||
| 245 | assert_eq!( | ||
| 246 | sha, | ||
| 247 | "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" | ||
| 248 | ); | ||
| 249 | let file = tmp.path().join("v1.0.0").join("app.tar.gz"); | ||
| 250 | assert_eq!(std::fs::read(&file).unwrap(), b"hello"); | ||
| 251 | let sha_file = tmp.path().join("v1.0.0").join("app.tar.gz.sha256"); | ||
| 252 | assert_eq!( | ||
| 253 | std::fs::read_to_string(&sha_file).unwrap(), | ||
| 254 | format!("{} app.tar.gz\n", sha) | ||
| 255 | ); | ||
| 256 | } | ||
| 257 | |||
| 258 | #[test] | ||
| 259 | fn duplicate_upload_rejected_without_force() { | ||
| 260 | let tmp = TempDir::new().unwrap(); | ||
| 261 | upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap(); | ||
| 262 | let err = upload(tmp.path(), "v1", "a.tar.gz", b"two", false).unwrap_err(); | ||
| 263 | assert!(matches!(err, ReleaseError::AlreadyExists(_))); | ||
| 264 | // original content untouched | ||
| 265 | assert_eq!( | ||
| 266 | std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(), | ||
| 267 | b"one" | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | |||
| 271 | #[test] | ||
| 272 | fn force_replaces_file_and_checksum() { | ||
| 273 | let tmp = TempDir::new().unwrap(); | ||
| 274 | 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(); | ||
| 276 | assert_eq!( | ||
| 277 | std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(), | ||
| 278 | b"two" | ||
| 279 | ); | ||
| 280 | assert!(std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256")) | ||
| 281 | .unwrap() | ||
| 282 | .starts_with(&sha2)); | ||
| 283 | } | ||
| 284 | |||
| 285 | #[test] | ||
| 286 | fn oversize_upload_rejected_and_cleaned_up() { | ||
| 287 | let tmp = TempDir::new().unwrap(); | ||
| 288 | let mut up = ReleaseUpload::begin(tmp.path(), "v1", "big.tar.gz", false, 4).unwrap(); | ||
| 289 | let err = up.write(b"12345").unwrap_err(); | ||
| 290 | assert!(matches!(err, ReleaseError::TooLarge(4))); | ||
| 291 | drop(up); | ||
| 292 | // no artifact, no stray temp files | ||
| 293 | assert!(!tmp.path().join("v1").join("big.tar.gz").exists()); | ||
| 294 | let leftovers: Vec<_> = std::fs::read_dir(tmp.path().join("v1")) | ||
| 295 | .unwrap() | ||
| 296 | .collect(); | ||
| 297 | assert!(leftovers.is_empty(), "temp files left behind: {:?}", leftovers); | ||
| 298 | } | ||
| 299 | |||
| 300 | #[test] | ||
| 301 | fn invalid_names_rejected() { | ||
| 302 | let tmp = TempDir::new().unwrap(); | ||
| 303 | assert!(matches!( | ||
| 304 | ReleaseUpload::begin(tmp.path(), "../v1", "a.tar.gz", false, 100), | ||
| 305 | Err(ReleaseError::InvalidName(_)) | ||
| 306 | )); | ||
| 307 | assert!(matches!( | ||
| 308 | ReleaseUpload::begin(tmp.path(), "v1", "../../a", false, 100), | ||
| 309 | Err(ReleaseError::InvalidName(_)) | ||
| 310 | )); | ||
| 311 | assert!(matches!( | ||
| 312 | delete_release(tmp.path(), "..", None), | ||
| 313 | Err(ReleaseError::InvalidName(_)) | ||
| 314 | )); | ||
| 315 | } | ||
| 316 | |||
| 317 | #[test] | ||
| 318 | fn list_orders_versions_newest_first() { | ||
| 319 | let tmp = TempDir::new().unwrap(); | ||
| 320 | upload(tmp.path(), "v1.0.0", "a.tar.gz", b"aaa", false).unwrap(); | ||
| 321 | std::thread::sleep(std::time::Duration::from_millis(20)); | ||
| 322 | upload(tmp.path(), "v1.1.0", "b.tar.gz", b"bbb", false).unwrap(); | ||
| 323 | |||
| 324 | let index = list_releases(tmp.path()).unwrap(); | ||
| 325 | assert_eq!(index.versions.len(), 2); | ||
| 326 | assert_eq!(index.versions[0].version, "v1.1.0"); | ||
| 327 | assert_eq!(index.versions[1].version, "v1.0.0"); | ||
| 328 | assert_eq!(index.versions[0].files.len(), 1); | ||
| 329 | assert_eq!(index.versions[0].files[0].name, "b.tar.gz"); | ||
| 330 | assert_eq!(index.versions[0].files[0].size, 3); | ||
| 331 | assert_eq!(index.versions[0].files[0].sha256.len(), 64); | ||
| 332 | assert!(!index.versions[0].published.is_empty()); | ||
| 333 | } | ||
| 334 | |||
| 335 | #[test] | ||
| 336 | fn list_missing_dir_is_empty() { | ||
| 337 | let tmp = TempDir::new().unwrap(); | ||
| 338 | let index = list_releases(&tmp.path().join("nope")).unwrap(); | ||
| 339 | assert!(index.versions.is_empty()); | ||
| 340 | } | ||
| 341 | |||
| 342 | #[test] | ||
| 343 | fn list_excludes_sha256_companions() { | ||
| 344 | let tmp = TempDir::new().unwrap(); | ||
| 345 | upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap(); | ||
| 346 | let index = list_releases(tmp.path()).unwrap(); | ||
| 347 | let names: Vec<_> = index.versions[0].files.iter().map(|f| &f.name).collect(); | ||
| 348 | assert_eq!(names, vec!["a.tar.gz"]); | ||
| 349 | } | ||
| 350 | |||
| 351 | #[test] | ||
| 352 | fn delete_file_and_version() { | ||
| 353 | let tmp = TempDir::new().unwrap(); | ||
| 354 | upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap(); | ||
| 355 | upload(tmp.path(), "v1", "b.tar.gz", b"y", false).unwrap(); | ||
| 356 | |||
| 357 | delete_release(tmp.path(), "v1", Some("a.tar.gz")).unwrap(); | ||
| 358 | assert!(!tmp.path().join("v1").join("a.tar.gz").exists()); | ||
| 359 | assert!(!tmp.path().join("v1").join("a.tar.gz.sha256").exists()); | ||
| 360 | assert!(tmp.path().join("v1").exists()); | ||
| 361 | |||
| 362 | // deleting the last file removes the version dir | ||
| 363 | delete_release(tmp.path(), "v1", Some("b.tar.gz")).unwrap(); | ||
| 364 | assert!(!tmp.path().join("v1").exists()); | ||
| 365 | } | ||
| 366 | |||
| 367 | #[test] | ||
| 368 | fn delete_whole_version_and_missing_targets_error() { | ||
| 369 | let tmp = TempDir::new().unwrap(); | ||
| 370 | upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap(); | ||
| 371 | delete_release(tmp.path(), "v1", None).unwrap(); | ||
| 372 | assert!(!tmp.path().join("v1").exists()); | ||
| 373 | |||
| 374 | assert!(matches!( | ||
| 375 | delete_release(tmp.path(), "v1", None), | ||
| 376 | Err(ReleaseError::NotFound(_)) | ||
| 377 | )); | ||
| 378 | assert!(matches!( | ||
| 379 | delete_release(tmp.path(), "v2", Some("z.tar.gz")), | ||
| 380 | Err(ReleaseError::NotFound(_)) | ||
| 381 | )); | ||
| 382 | } | ||
| 383 | } | ||