a73x

9b324f39

Add the server-side release artifact store

a73x   2026-08-08 14:26

Commit message
Add the server-side release artifact store

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 }