a73x

5388dac8

Add shared release name validation

a73x   2026-08-08 14:19

Commit message
Add shared release name validation

docs/superpowers/plans/2026-08-08-release-packages.md
Old New
@@ -0,0 +1,2339 @@
1 # Release Packages Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Upload release artifacts (tar.gz etc.) to the git-collab server over SSH and distribute them via public HTTP URLs.
6
7 **Architecture:** A new `collab-release` SSH exec verb (alongside `git-upload-pack`/`git-receive-pack`) handles upload/list/delete, gated by the existing per-repo read/write policies. Artifacts are plain files under `{repo}.git/collab/releases/{version}/` with `.sha256` companions — the filesystem is the index. The web server gains a releases page and a streaming download route. The CLI gains `git-collab release publish/list/delete`, which shells out to `ssh` (honoring `GIT_COLLAB_SSH_COMMAND`).
8
9 **Tech Stack:** Rust 2021, russh 0.46 (SSH server), axum 0.8 + askama (HTTP), new deps: `sha2`, `tokio-util` (io feature), `tempfile` promoted from dev-dependency.
10
11 **Spec:** `docs/superpowers/specs/2026-08-08-release-packages-design.md`
12
13 **Codebase facts the implementer needs:**
14
15 - The workspace has TWO crate roots: the lib (`src/lib.rs`, used by the `git-collab` CLI bin) and the server bin (`src/server/main.rs`, which declares `mod config; mod http; mod repos; mod ssh;`). Server modules reference each other as `crate::…` *within the server bin* and can also use the lib as `git_collab::…`.
16 - SSH exec commands are parsed in `src/server/ssh/session.rs::parse_git_command` and authorized via `crate::repos::entry_for_path(...).policy.allows_read/allows_write(principal)`. The repo argument is a path relative to `repos_dir`, sanitized by `resolve_repo_path`.
17 - The SSH handler currently has NO `channel_eof` implementation. Upload-until-EOF requires one.
18 - HTTP handlers live in `src/server/http/repo/*.rs`, use askama templates from `src/server/http/templates/`, and gate access with `entry.policy.allows_anonymous_ui()` (HTML pages, via `open_repo`) or `entry.policy.allows_anonymous_http()` (git data, see `git_http.rs`).
19 - Every template extending `repo_base.html` must provide fields: `site_title`, `repo_name`, `active_section`, `open_patches`, `open_issues`.
20 - The test harness `tests/common/mod.rs::ServerHarness` starts a real `git-collab-server` subprocess but currently discards the SSH address and writes an empty `authorized_keys`. Tests talk raw HTTP over `TcpStream`; `get()` assumes UTF-8 bodies.
21 - Run tests with `cargo test`, lints with `cargo clippy --all-targets`.
22 - Commit messages follow the existing plain imperative style (e.g. "Enforce one review vote per author per revision"), with the Claude Co-Authored-By trailer.
23
24 ---
25
26 ### Task 1: Shared name validation in the lib
27
28 Version and filename validation is needed by both the CLI (client-side check) and the server (defense). Put it in the lib so both use one implementation.
29
30 **Files:**
31 - Create: `src/release.rs`
32 - Modify: `src/lib.rs` (add `pub mod release;`)
33
34 - [ ] **Step 1: Write the failing tests**
35
36 Create `src/release.rs`:
37
38 ```rust
39 //! Client-side release commands and shared release name validation.
40
41 /// Maximum length in bytes for a release version or filename.
42 pub const MAX_NAME_LEN: usize = 128;
43
44 /// Validate a release version or filename: ASCII alphanumeric first char,
45 /// then alphanumeric plus `.`, `_`, `-`. No slashes, no leading dot, max 128 bytes.
46 pub fn validate_name(name: &str) -> bool {
47 todo!()
48 }
49
50 #[cfg(test)]
51 mod tests {
52 use super::*;
53
54 #[test]
55 fn accepts_typical_names() {
56 assert!(validate_name("v1.2.0"));
57 assert!(validate_name("app-x86_64.tar.gz"));
58 assert!(validate_name("1.0"));
59 assert!(validate_name("RELEASE_2026"));
60 }
61
62 #[test]
63 fn rejects_traversal_and_separators() {
64 assert!(!validate_name("../etc"));
65 assert!(!validate_name("a/b"));
66 assert!(!validate_name("a\\b"));
67 assert!(!validate_name(".hidden"));
68 assert!(!validate_name(".."));
69 }
70
71 #[test]
72 fn rejects_empty_weird_and_overlong() {
73 assert!(!validate_name(""));
74 assert!(!validate_name("näme"));
75 assert!(!validate_name("a b"));
76 assert!(!validate_name("-leading-dash"));
77 assert!(!validate_name(&"a".repeat(129)));
78 assert!(validate_name(&"a".repeat(128)));
79 }
80 }
81 ```
82
83 In `src/lib.rs`, add `pub mod release;` to the module list (alphabetical, after `pub mod patch;`).
84
85 - [ ] **Step 2: Run tests to verify they fail**
86
87 Run: `cargo test --lib release::`
88 Expected: FAIL (panic at `todo!()`)
89
90 - [ ] **Step 3: Implement**
91
92 Replace the `todo!()` body:
93
94 ```rust
95 pub fn validate_name(name: &str) -> bool {
96 if name.is_empty() || name.len() > MAX_NAME_LEN {
97 return false;
98 }
99 let mut chars = name.chars();
100 let first = chars.next().unwrap();
101 if !first.is_ascii_alphanumeric() {
102 return false;
103 }
104 chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
105 }
106 ```
107
108 - [ ] **Step 4: Run tests to verify they pass**
109
110 Run: `cargo test --lib release::`
111 Expected: PASS (3 tests)
112
113 - [ ] **Step 5: Commit**
114
115 ```bash
116 git add src/release.rs src/lib.rs
117 git commit -m "Add shared release name validation"
118 ```
119
120 ---
121
122 ### Task 2: Server release store
123
124 Filesystem store: atomic uploads with SHA-256, listing, deletion. Lives in the server bin crate.
125
126 **Files:**
127 - Modify: `Cargo.toml` (add `sha2`, `tokio-util`; move `tempfile` to `[dependencies]`)
128 - Create: `src/server/releases.rs`
129 - Modify: `src/server/main.rs` (add `mod releases;`)
130
131 - [ ] **Step 1: Add dependencies**
132
133 In `Cargo.toml` `[dependencies]`, add:
134
135 ```toml
136 sha2 = "0.10"
137 tempfile = "3"
138 tokio-util = { version = "0.7", features = ["io"] }
139 ```
140
141 Remove `tempfile = "3"` from `[dev-dependencies]` (it is now a regular dependency and stays visible to tests).
142
143 - [ ] **Step 2: Write the module skeleton and failing tests**
144
145 Create `src/server/releases.rs`:
146
147 ```rust
148 //! Filesystem-backed release artifact store.
149 //!
150 //! Layout: `<releases_dir>/<version>/<filename>` plus `<filename>.sha256`
151 //! (sha256sum-compatible: "<hex> <filename>\n"). No manifest — the
152 //! filesystem is the index.
153
154 use std::io::Write;
155 use std::path::{Path, PathBuf};
156
157 use serde::Serialize;
158 use sha2::{Digest, Sha256};
159 use tempfile::NamedTempFile;
160
161 use git_collab::release::validate_name;
162
163 pub const DEFAULT_MAX_RELEASE_SIZE: u64 = 1024 * 1024 * 1024; // 1 GiB
164
165 #[derive(Debug, thiserror::Error)]
166 pub enum ReleaseError {
167 #[error("invalid name: {0}")]
168 InvalidName(String),
169 #[error("{0} already exists (use --force to replace)")]
170 AlreadyExists(String),
171 #[error("file exceeds maximum release size ({0} bytes)")]
172 TooLarge(u64),
173 #[error("not found: {0}")]
174 NotFound(String),
175 #[error(transparent)]
176 Io(#[from] std::io::Error),
177 }
178
179 /// The releases directory for a repo entry: `<gitdir>/collab/releases`.
180 pub fn releases_dir(entry: &crate::repos::RepoEntry) -> PathBuf {
181 if entry.bare {
182 entry.path.join("collab").join("releases")
183 } else {
184 entry.path.join(".git").join("collab").join("releases")
185 }
186 }
187
188 /// An in-progress streamed upload. Bytes go to a temp file in the version
189 /// directory; `finish()` atomically renames into place and writes the
190 /// `.sha256` companion. Dropping without `finish()` cleans up the temp file.
191 pub struct ReleaseUpload {
192 temp: NamedTempFile,
193 hasher: Sha256,
194 written: u64,
195 max_size: u64,
196 dest: PathBuf,
197 sha_dest: PathBuf,
198 filename: String,
199 force: bool,
200 }
201
202 impl ReleaseUpload {
203 pub fn begin(
204 releases_dir: &Path,
205 version: &str,
206 filename: &str,
207 force: bool,
208 max_size: u64,
209 ) -> Result<Self, ReleaseError> {
210 todo!()
211 }
212
213 pub fn write(&mut self, chunk: &[u8]) -> Result<(), ReleaseError> {
214 todo!()
215 }
216
217 /// Finalize: atomic rename + write `.sha256`. Returns the hex digest.
218 pub fn finish(self) -> Result<String, ReleaseError> {
219 todo!()
220 }
221 }
222
223 #[derive(Debug, Serialize)]
224 pub struct ReleaseFile {
225 pub name: String,
226 pub size: u64,
227 pub sha256: String,
228 }
229
230 #[derive(Debug, Serialize)]
231 pub struct ReleaseVersion {
232 pub version: String,
233 pub published: String,
234 pub files: Vec<ReleaseFile>,
235 }
236
237 #[derive(Debug, Serialize)]
238 pub struct ReleaseIndex {
239 pub versions: Vec<ReleaseVersion>,
240 }
241
242 /// List versions newest-first (by directory mtime), files alphabetically.
243 /// `.sha256` companions and dotfiles are not listed as files.
244 /// A missing releases dir is an empty index.
245 pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError> {
246 todo!()
247 }
248
249 /// Delete one file (and its `.sha256`) or, with `filename: None`, a whole
250 /// version. Removing the last file of a version removes the version dir.
251 pub fn delete_release(
252 releases_dir: &Path,
253 version: &str,
254 filename: Option<&str>,
255 ) -> Result<(), ReleaseError> {
256 todo!()
257 }
258
259 #[cfg(test)]
260 mod tests {
261 use super::*;
262 use tempfile::TempDir;
263
264 fn upload(dir: &Path, version: &str, name: &str, content: &[u8], force: bool) -> Result<String, ReleaseError> {
265 let mut up = ReleaseUpload::begin(dir, version, name, force, DEFAULT_MAX_RELEASE_SIZE)?;
266 up.write(content)?;
267 up.finish()
268 }
269
270 #[test]
271 fn upload_writes_file_and_checksum() {
272 let tmp = TempDir::new().unwrap();
273 let sha = upload(tmp.path(), "v1.0.0", "app.tar.gz", b"hello", false).unwrap();
274 // sha256 of "hello"
275 assert_eq!(
276 sha,
277 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
278 );
279 let file = tmp.path().join("v1.0.0").join("app.tar.gz");
280 assert_eq!(std::fs::read(&file).unwrap(), b"hello");
281 let sha_file = tmp.path().join("v1.0.0").join("app.tar.gz.sha256");
282 assert_eq!(
283 std::fs::read_to_string(&sha_file).unwrap(),
284 format!("{} app.tar.gz\n", sha)
285 );
286 }
287
288 #[test]
289 fn duplicate_upload_rejected_without_force() {
290 let tmp = TempDir::new().unwrap();
291 upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap();
292 let err = upload(tmp.path(), "v1", "a.tar.gz", b"two", false).unwrap_err();
293 assert!(matches!(err, ReleaseError::AlreadyExists(_)));
294 // original content untouched
295 assert_eq!(
296 std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(),
297 b"one"
298 );
299 }
300
301 #[test]
302 fn force_replaces_file_and_checksum() {
303 let tmp = TempDir::new().unwrap();
304 upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap();
305 let sha2 = upload(tmp.path(), "v1", "a.tar.gz", b"two", true).unwrap();
306 assert_eq!(
307 std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(),
308 b"two"
309 );
310 assert!(std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256"))
311 .unwrap()
312 .starts_with(&sha2));
313 }
314
315 #[test]
316 fn oversize_upload_rejected_and_cleaned_up() {
317 let tmp = TempDir::new().unwrap();
318 let mut up = ReleaseUpload::begin(tmp.path(), "v1", "big.tar.gz", false, 4).unwrap();
319 let err = up.write(b"12345").unwrap_err();
320 assert!(matches!(err, ReleaseError::TooLarge(4)));
321 drop(up);
322 // no artifact, no stray temp files
323 assert!(!tmp.path().join("v1").join("big.tar.gz").exists());
324 let leftovers: Vec<_> = std::fs::read_dir(tmp.path().join("v1"))
325 .unwrap()
326 .collect();
327 assert!(leftovers.is_empty(), "temp files left behind: {:?}", leftovers);
328 }
329
330 #[test]
331 fn invalid_names_rejected() {
332 let tmp = TempDir::new().unwrap();
333 assert!(matches!(
334 ReleaseUpload::begin(tmp.path(), "../v1", "a.tar.gz", false, 100),
335 Err(ReleaseError::InvalidName(_))
336 ));
337 assert!(matches!(
338 ReleaseUpload::begin(tmp.path(), "v1", "../../a", false, 100),
339 Err(ReleaseError::InvalidName(_))
340 ));
341 assert!(matches!(
342 delete_release(tmp.path(), "..", None),
343 Err(ReleaseError::InvalidName(_))
344 ));
345 }
346
347 #[test]
348 fn list_orders_versions_newest_first() {
349 let tmp = TempDir::new().unwrap();
350 upload(tmp.path(), "v1.0.0", "a.tar.gz", b"aaa", false).unwrap();
351 std::thread::sleep(std::time::Duration::from_millis(20));
352 upload(tmp.path(), "v1.1.0", "b.tar.gz", b"bbb", false).unwrap();
353
354 let index = list_releases(tmp.path()).unwrap();
355 assert_eq!(index.versions.len(), 2);
356 assert_eq!(index.versions[0].version, "v1.1.0");
357 assert_eq!(index.versions[1].version, "v1.0.0");
358 assert_eq!(index.versions[0].files.len(), 1);
359 assert_eq!(index.versions[0].files[0].name, "b.tar.gz");
360 assert_eq!(index.versions[0].files[0].size, 3);
361 assert_eq!(index.versions[0].files[0].sha256.len(), 64);
362 assert!(!index.versions[0].published.is_empty());
363 }
364
365 #[test]
366 fn list_missing_dir_is_empty() {
367 let tmp = TempDir::new().unwrap();
368 let index = list_releases(&tmp.path().join("nope")).unwrap();
369 assert!(index.versions.is_empty());
370 }
371
372 #[test]
373 fn list_excludes_sha256_companions() {
374 let tmp = TempDir::new().unwrap();
375 upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
376 let index = list_releases(tmp.path()).unwrap();
377 let names: Vec<_> = index.versions[0].files.iter().map(|f| &f.name).collect();
378 assert_eq!(names, vec!["a.tar.gz"]);
379 }
380
381 #[test]
382 fn delete_file_and_version() {
383 let tmp = TempDir::new().unwrap();
384 upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
385 upload(tmp.path(), "v1", "b.tar.gz", b"y", false).unwrap();
386
387 delete_release(tmp.path(), "v1", Some("a.tar.gz")).unwrap();
388 assert!(!tmp.path().join("v1").join("a.tar.gz").exists());
389 assert!(!tmp.path().join("v1").join("a.tar.gz.sha256").exists());
390 assert!(tmp.path().join("v1").exists());
391
392 // deleting the last file removes the version dir
393 delete_release(tmp.path(), "v1", Some("b.tar.gz")).unwrap();
394 assert!(!tmp.path().join("v1").exists());
395 }
396
397 #[test]
398 fn delete_whole_version_and_missing_targets_error() {
399 let tmp = TempDir::new().unwrap();
400 upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
401 delete_release(tmp.path(), "v1", None).unwrap();
402 assert!(!tmp.path().join("v1").exists());
403
404 assert!(matches!(
405 delete_release(tmp.path(), "v1", None),
406 Err(ReleaseError::NotFound(_))
407 ));
408 assert!(matches!(
409 delete_release(tmp.path(), "v2", Some("z.tar.gz")),
410 Err(ReleaseError::NotFound(_))
411 ));
412 }
413 }
414 ```
415
416 In `src/server/main.rs`, add `mod releases;` after `mod http;` (the module is exercised by tests now and wired into handlers in later tasks; if `cargo clippy` flags dead code in the meantime, that resolves in Task 5).
417
418 - [ ] **Step 3: Run tests to verify they fail**
419
420 Run: `cargo test --bin git-collab-server releases::`
421 Expected: FAIL (panics at `todo!()`)
422
423 - [ ] **Step 4: Implement the store**
424
425 Replace the three `todo!()` bodies:
426
427 ```rust
428 pub fn begin(
429 releases_dir: &Path,
430 version: &str,
431 filename: &str,
432 force: bool,
433 max_size: u64,
434 ) -> Result<Self, ReleaseError> {
435 if !validate_name(version) {
436 return Err(ReleaseError::InvalidName(version.to_string()));
437 }
438 if !validate_name(filename) {
439 return Err(ReleaseError::InvalidName(filename.to_string()));
440 }
441 let version_dir = releases_dir.join(version);
442 std::fs::create_dir_all(&version_dir)?;
443 let dest = version_dir.join(filename);
444 if dest.exists() && !force {
445 return Err(ReleaseError::AlreadyExists(format!("{}/{}", version, filename)));
446 }
447 let temp = NamedTempFile::new_in(&version_dir)?;
448 let sha_dest = version_dir.join(format!("{}.sha256", filename));
449 Ok(Self {
450 temp,
451 hasher: Sha256::new(),
452 written: 0,
453 max_size,
454 dest,
455 sha_dest,
456 filename: filename.to_string(),
457 force,
458 })
459 }
460
461 pub fn write(&mut self, chunk: &[u8]) -> Result<(), ReleaseError> {
462 self.written += chunk.len() as u64;
463 if self.written > self.max_size {
464 return Err(ReleaseError::TooLarge(self.max_size));
465 }
466 self.hasher.update(chunk);
467 self.temp.write_all(chunk)?;
468 Ok(())
469 }
470
471 pub fn finish(mut self) -> Result<String, ReleaseError> {
472 self.temp.flush()?;
473 let digest = self.hasher.finalize();
474 let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
475 if self.force {
476 self.temp
477 .persist(&self.dest)
478 .map_err(|e| ReleaseError::Io(e.error))?;
479 } else {
480 self.temp
481 .persist_noclobber(&self.dest)
482 .map_err(|e| ReleaseError::Io(e.error))?;
483 }
484 std::fs::write(&self.sha_dest, format!("{} {}\n", hex, self.filename))?;
485 Ok(hex)
486 }
487 ```
488
489 ```rust
490 pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError> {
491 let mut versions = Vec::new();
492 let read_dir = match std::fs::read_dir(releases_dir) {
493 Ok(rd) => rd,
494 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
495 return Ok(ReleaseIndex { versions })
496 }
497 Err(e) => return Err(e.into()),
498 };
499
500 for entry in read_dir {
501 let entry = entry?;
502 if !entry.path().is_dir() {
503 continue;
504 }
505 let version = entry.file_name().to_string_lossy().to_string();
506 if !validate_name(&version) {
507 continue;
508 }
509 let mtime = entry
510 .metadata()?
511 .modified()
512 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
513 let published = chrono::DateTime::<chrono::Utc>::from(mtime)
514 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
515
516 let mut files = Vec::new();
517 for file_entry in std::fs::read_dir(entry.path())? {
518 let file_entry = file_entry?;
519 let name = file_entry.file_name().to_string_lossy().to_string();
520 if name.starts_with('.') || name.ends_with(".sha256") || !file_entry.path().is_file() {
521 continue;
522 }
523 let size = file_entry.metadata()?.len();
524 let sha256 = std::fs::read_to_string(
525 entry.path().join(format!("{}.sha256", name)),
526 )
527 .ok()
528 .and_then(|s| s.split_whitespace().next().map(|t| t.to_string()))
529 .unwrap_or_default();
530 files.push(ReleaseFile { name, size, sha256 });
531 }
532 files.sort_by(|a, b| a.name.cmp(&b.name));
533 versions.push((mtime, ReleaseVersion { version, published, files }));
534 }
535
536 versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.version.cmp(&b.1.version)));
537 Ok(ReleaseIndex {
538 versions: versions.into_iter().map(|(_, v)| v).collect(),
539 })
540 }
541
542 pub fn delete_release(
543 releases_dir: &Path,
544 version: &str,
545 filename: Option<&str>,
546 ) -> Result<(), ReleaseError> {
547 if !validate_name(version) {
548 return Err(ReleaseError::InvalidName(version.to_string()));
549 }
550 let version_dir = releases_dir.join(version);
551 if !version_dir.is_dir() {
552 return Err(ReleaseError::NotFound(version.to_string()));
553 }
554 match filename {
555 None => {
556 std::fs::remove_dir_all(&version_dir)?;
557 }
558 Some(name) => {
559 if !validate_name(name) {
560 return Err(ReleaseError::InvalidName(name.to_string()));
561 }
562 let file = version_dir.join(name);
563 if !file.is_file() {
564 return Err(ReleaseError::NotFound(format!("{}/{}", version, name)));
565 }
566 std::fs::remove_file(&file)?;
567 let _ = std::fs::remove_file(version_dir.join(format!("{}.sha256", name)));
568 let is_empty = std::fs::read_dir(&version_dir)?.next().is_none();
569 if is_empty {
570 std::fs::remove_dir(&version_dir)?;
571 }
572 }
573 }
574 Ok(())
575 }
576 ```
577
578 Note the `NotFound` test for `delete_release(tmp.path(), "v2", Some("z.tar.gz"))`: the version dir doesn't exist either, so the version-level check returns `NotFound("v2")` — which still matches the test's `matches!(.., Err(ReleaseError::NotFound(_)))`.
579
580 - [ ] **Step 5: Run tests to verify they pass**
581
582 Run: `cargo test --bin git-collab-server releases::`
583 Expected: PASS (10 tests)
584
585 - [ ] **Step 6: Commit**
586
587 ```bash
588 git add Cargo.toml Cargo.lock src/server/releases.rs src/server/main.rs
589 git commit -m "Add server-side release artifact store"
590 ```
591
592 ---
593
594 ### Task 3: `max_release_size` server config
595
596 **Files:**
597 - Modify: `src/server/config.rs`
598 - Modify: `src/server/ssh/session.rs:16-20` (`SshServerConfig`)
599 - Modify: `src/server/main.rs:66-69` (wiring)
600
601 - [ ] **Step 1: Write the failing test**
602
603 In `src/server/config.rs` tests, add:
604
605 ```rust
606 #[test]
607 fn parse_max_release_size() {
608 let toml = r#"
609 repos_dir = "/srv/git"
610 authorized_keys = "/keys"
611 max_release_size = 1024
612 "#;
613 let config = ServerConfig::from_toml(toml).unwrap();
614 assert_eq!(config.max_release_size, 1024);
615 }
616 ```
617
618 And extend the existing `parse_minimal_config_uses_defaults` test with:
619
620 ```rust
621 assert_eq!(config.max_release_size, 1024 * 1024 * 1024);
622 ```
623
624 - [ ] **Step 2: Run test to verify it fails**
625
626 Run: `cargo test --bin git-collab-server config::`
627 Expected: FAIL (no field `max_release_size`)
628
629 - [ ] **Step 3: Implement**
630
631 In `ServerConfig`, add the field:
632
633 ```rust
634 #[serde(default = "default_max_release_size")]
635 pub max_release_size: u64,
636 ```
637
638 and next to the other default fns:
639
640 ```rust
641 fn default_max_release_size() -> u64 {
642 crate::releases::DEFAULT_MAX_RELEASE_SIZE
643 }
644 ```
645
646 In `src/server/ssh/session.rs`, extend `SshServerConfig`:
647
648 ```rust
649 #[derive(Debug, Clone)]
650 pub struct SshServerConfig {
651 pub repos_dir: PathBuf,
652 pub authorized_keys_path: PathBuf,
653 pub max_release_size: u64,
654 }
655 ```
656
657 In `src/server/main.rs`, extend the `SshServerConfig` construction:
658
659 ```rust
660 let ssh_config = ssh::session::SshServerConfig {
661 repos_dir: config.repos_dir.clone(),
662 authorized_keys_path: config.authorized_keys.clone(),
663 max_release_size: config.max_release_size,
664 };
665 ```
666
667 - [ ] **Step 4: Run tests to verify they pass**
668
669 Run: `cargo test --bin git-collab-server config::`
670 Expected: PASS
671
672 - [ ] **Step 5: Commit**
673
674 ```bash
675 git add src/server/config.rs src/server/ssh/session.rs src/server/main.rs
676 git commit -m "Add max_release_size server config option"
677 ```
678
679 ---
680
681 ### Task 4: Parse `collab-release` exec commands
682
683 **Files:**
684 - Modify: `src/server/ssh/session.rs` (new enum + parser + tests; keep `parse_git_command` as-is)
685
686 - [ ] **Step 1: Write the failing tests**
687
688 Add to the `tests` module in `src/server/ssh/session.rs`:
689
690 ```rust
691 #[test]
692 fn parse_release_upload() {
693 let cmd = parse_exec_command("collab-release upload 'myrepo.git' 'v1.0.0' 'app.tar.gz'");
694 assert_eq!(
695 cmd,
696 Some(ExecCommand::Release(ReleaseCmd::Upload {
697 repo: "myrepo.git".into(),
698 version: "v1.0.0".into(),
699 filename: "app.tar.gz".into(),
700 force: false,
701 }))
702 );
703 }
704
705 #[test]
706 fn parse_release_upload_force() {
707 let cmd =
708 parse_exec_command("collab-release upload 'myrepo.git' 'v1' 'a.tar.gz' --force");
709 assert_eq!(
710 cmd,
711 Some(ExecCommand::Release(ReleaseCmd::Upload {
712 repo: "myrepo.git".into(),
713 version: "v1".into(),
714 filename: "a.tar.gz".into(),
715 force: true,
716 }))
717 );
718 }
719
720 #[test]
721 fn parse_release_list_and_delete() {
722 assert_eq!(
723 parse_exec_command("collab-release list 'myrepo.git'"),
724 Some(ExecCommand::Release(ReleaseCmd::List {
725 repo: "myrepo.git".into()
726 }))
727 );
728 assert_eq!(
729 parse_exec_command("collab-release delete 'myrepo.git' 'v1'"),
730 Some(ExecCommand::Release(ReleaseCmd::Delete {
731 repo: "myrepo.git".into(),
732 version: "v1".into(),
733 filename: None,
734 }))
735 );
736 assert_eq!(
737 parse_exec_command("collab-release delete 'myrepo.git' 'v1' 'a.tar.gz'"),
738 Some(ExecCommand::Release(ReleaseCmd::Delete {
739 repo: "myrepo.git".into(),
740 version: "v1".into(),
741 filename: Some("a.tar.gz".into()),
742 }))
743 );
744 }
745
746 #[test]
747 fn parse_exec_command_handles_git_commands() {
748 assert_eq!(
749 parse_exec_command("git-upload-pack '/srv/git/repo.git'"),
750 Some(ExecCommand::Git {
751 cmd: "git-upload-pack".into(),
752 repo: "/srv/git/repo.git".into()
753 })
754 );
755 }
756
757 #[test]
758 fn parse_release_rejects_malformed() {
759 assert_eq!(parse_exec_command("collab-release"), None);
760 assert_eq!(parse_exec_command("collab-release frobnicate 'r'"), None);
761 assert_eq!(parse_exec_command("collab-release upload 'r'"), None);
762 assert_eq!(parse_exec_command("collab-release upload 'r' 'v'"), None);
763 assert_eq!(
764 parse_exec_command("collab-release upload 'r' 'v' 'f' --frob"),
765 None
766 );
767 assert_eq!(parse_exec_command("collab-release list 'r' extra"), None);
768 assert_eq!(parse_exec_command("collab-release upload 'unclosed"), None);
769 assert_eq!(parse_exec_command("rm -rf /"), None);
770 }
771 ```
772
773 - [ ] **Step 2: Run tests to verify they fail**
774
775 Run: `cargo test --bin git-collab-server session::`
776 Expected: FAIL to compile (no `parse_exec_command`)
777
778 - [ ] **Step 3: Implement**
779
780 Add below `parse_git_command` in `session.rs`:
781
782 ```rust
783 #[derive(Debug, Clone, PartialEq, Eq)]
784 pub enum ExecCommand {
785 Git { cmd: String, repo: String },
786 Release(ReleaseCmd),
787 }
788
789 #[derive(Debug, Clone, PartialEq, Eq)]
790 pub enum ReleaseCmd {
791 Upload {
792 repo: String,
793 version: String,
794 filename: String,
795 force: bool,
796 },
797 List {
798 repo: String,
799 },
800 Delete {
801 repo: String,
802 version: String,
803 filename: Option<String>,
804 },
805 }
806
807 impl ReleaseCmd {
808 pub fn repo(&self) -> &str {
809 match self {
810 ReleaseCmd::Upload { repo, .. }
811 | ReleaseCmd::List { repo }
812 | ReleaseCmd::Delete { repo, .. } => repo,
813 }
814 }
815 }
816
817 /// Split an exec string into tokens, honoring single/double quotes.
818 /// Returns None on unbalanced quotes. No escape sequences (release names
819 /// have a restricted charset; git paths never need them here either).
820 fn shell_tokens(input: &str) -> Option<Vec<String>> {
821 let mut tokens = Vec::new();
822 let mut current = String::new();
823 let mut in_token = false;
824 let mut quote: Option<char> = None;
825
826 for c in input.trim().chars() {
827 match quote {
828 Some(q) if c == q => quote = None,
829 Some(_) => current.push(c),
830 None if c == '\'' || c == '"' => {
831 quote = Some(c);
832 in_token = true;
833 }
834 None if c.is_whitespace() => {
835 if in_token {
836 tokens.push(std::mem::take(&mut current));
837 in_token = false;
838 }
839 }
840 None => {
841 current.push(c);
842 in_token = true;
843 }
844 }
845 }
846 if quote.is_some() {
847 return None;
848 }
849 if in_token {
850 tokens.push(current);
851 }
852 Some(tokens)
853 }
854
855 /// Parse an SSH exec request into an allowed command, or None if rejected.
856 pub fn parse_exec_command(data: &str) -> Option<ExecCommand> {
857 if let Some((cmd, repo)) = parse_git_command(data) {
858 return Some(ExecCommand::Git {
859 cmd: cmd.to_string(),
860 repo: repo.to_string(),
861 });
862 }
863
864 let tokens = shell_tokens(data)?;
865 let mut it = tokens.into_iter();
866 if it.next()? != "collab-release" {
867 return None;
868 }
869 let verb = it.next()?;
870 let rest: Vec<String> = it.collect();
871 match (verb.as_str(), rest.as_slice()) {
872 ("upload", [repo, version, filename]) => Some(ExecCommand::Release(ReleaseCmd::Upload {
873 repo: repo.clone(),
874 version: version.clone(),
875 filename: filename.clone(),
876 force: false,
877 })),
878 ("upload", [repo, version, filename, flag]) if flag == "--force" => {
879 Some(ExecCommand::Release(ReleaseCmd::Upload {
880 repo: repo.clone(),
881 version: version.clone(),
882 filename: filename.clone(),
883 force: true,
884 }))
885 }
886 ("list", [repo]) => Some(ExecCommand::Release(ReleaseCmd::List { repo: repo.clone() })),
887 ("delete", [repo, version]) => Some(ExecCommand::Release(ReleaseCmd::Delete {
888 repo: repo.clone(),
889 version: version.clone(),
890 filename: None,
891 })),
892 ("delete", [repo, version, filename]) => Some(ExecCommand::Release(ReleaseCmd::Delete {
893 repo: repo.clone(),
894 version: version.clone(),
895 filename: Some(filename.clone()),
896 })),
897 _ => None,
898 }
899 }
900 ```
901
902 - [ ] **Step 4: Run tests to verify they pass**
903
904 Run: `cargo test --bin git-collab-server session::`
905 Expected: PASS (all old + 6 new tests)
906
907 - [ ] **Step 5: Commit**
908
909 ```bash
910 git add src/server/ssh/session.rs
911 git commit -m "Parse collab-release SSH exec commands"
912 ```
913
914 ---
915
916 ### Task 5: SSH client support in the test harness
917
918 Infrastructure for end-to-end SSH tests: a real OpenSSH client talking to the russh server. The canary test proves interop before any release logic exists.
919
920 **Files:**
921 - Modify: `tests/common/mod.rs` (`ServerHarness`)
922 - Create: `tests/release_server_test.rs`
923
924 - [ ] **Step 1: Add harness support**
925
926 In `tests/common/mod.rs`:
927
928 1. Add `ssh_addr: SocketAddr` to `struct ServerHarness` (after `http_addr`).
929 2. In `ServerHarness::new`, replace the body with a delegation and keep behavior identical:
930
931 ```rust
932 pub fn new(repo_name: &str) -> Self {
933 Self::new_with_extra_config(repo_name, "")
934 }
935
936 /// Like `new`, but appends extra lines to the server config
937 /// (e.g. "max_release_size = 1024").
938 pub fn new_with_extra_config(repo_name: &str, extra_config: &str) -> Self {
939 // ... existing body of new() ...
940 }
941 ```
942
943 Inside, change the config write to append the extra lines and stop discarding the SSH address:
944
945 ```rust
946 std::fs::write(
947 &config_path,
948 format!(
949 "repos_dir = {:?}\nhttp_bind = \"{}\"\nssh_bind = \"{}\"\nauthorized_keys = {:?}\nsite_title = \"git-collab test\"\n{}",
950 repos_dir,
951 http_addr,
952 ssh_addr,
953 authorized_keys,
954 extra_config,
955 ),
956 )
957 .unwrap();
958 ```
959
960 and construct with `ssh_addr` included:
961
962 ```rust
963 let mut harness = Self {
964 root,
965 repo_name: repo_name.to_string(),
966 work_repo,
967 server,
968 http_addr,
969 ssh_addr,
970 };
971 ```
972
973 3. Add the SSH helper methods to `impl ServerHarness`:
974
975 ```rust
976 /// Path to the server's repos dir (for on-disk assertions).
977 pub fn repos_dir(&self) -> PathBuf {
978 self.root.path().join("repos")
979 }
980
981 /// Generate a client SSH keypair (once) and authorize it. Returns the key path.
982 pub fn ssh_client_key(&self) -> PathBuf {
983 let key_path = self.root.path().join("id_ed25519");
984 if !key_path.exists() {
985 let output = Command::new("ssh-keygen")
986 .args(["-t", "ed25519", "-N", "", "-q", "-f", key_path.to_str().unwrap()])
987 .output()
988 .expect("failed to run ssh-keygen");
989 assert!(
990 output.status.success(),
991 "ssh-keygen failed: {}",
992 String::from_utf8_lossy(&output.stderr)
993 );
994 let pubkey =
995 std::fs::read_to_string(key_path.with_extension("pub")).unwrap();
996 std::fs::write(self.root.path().join("authorized_keys"), pubkey).unwrap();
997 }
998 key_path
999 }
1000
1001 /// The ssh client options needed to reach this test server, as a single
1002 /// command string usable both directly and as GIT_COLLAB_SSH_COMMAND.
1003 pub fn ssh_command_string(&self) -> String {
1004 format!(
1005 "ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o BatchMode=yes",
1006 self.ssh_client_key().display()
1007 )
1008 }
1009
1010 /// Run a remote command over SSH with the given stdin bytes.
1011 pub fn ssh_exec_with_stdin(&self, remote_cmd: &str, stdin: &[u8]) -> Output {
1012 let key = self.ssh_client_key();
1013 let mut child = Command::new("ssh")
1014 .args([
1015 "-p",
1016 &self.ssh_addr.port().to_string(),
1017 "-i",
1018 key.to_str().unwrap(),
1019 "-o",
1020 "StrictHostKeyChecking=no",
1021 "-o",
1022 "UserKnownHostsFile=/dev/null",
1023 "-o",
1024 "IdentitiesOnly=yes",
1025 "-o",
1026 "BatchMode=yes",
1027 "git@127.0.0.1",
1028 remote_cmd,
1029 ])
1030 .stdin(Stdio::piped())
1031 .stdout(Stdio::piped())
1032 .stderr(Stdio::piped())
1033 .spawn()
1034 .expect("failed to spawn ssh");
1035 child
1036 .stdin
1037 .take()
1038 .unwrap()
1039 .write_all(stdin)
1040 .expect("failed to write ssh stdin");
1041 // stdin handle drops here -> EOF on the channel
1042 child.wait_with_output().expect("failed to wait for ssh")
1043 }
1044
1045 pub fn ssh_exec(&self, remote_cmd: &str) -> Output {
1046 self.ssh_exec_with_stdin(remote_cmd, b"")
1047 }
1048
1049 /// ssh:// URL for the harness repo, for use as a git-collab remote.
1050 pub fn repo_ssh_url(&self) -> String {
1051 format!(
1052 "ssh://git@127.0.0.1:{}/{}.git",
1053 self.ssh_addr.port(),
1054 self.repo_name
1055 )
1056 }
1057 ```
1058
1059 4. Add a binary-safe HTTP GET (below `get`):
1060
1061 ```rust
1062 /// Like `get`, but returns the raw body bytes and full header block.
1063 pub fn get_bytes(&self, path: &str) -> (String, Vec<u8>) {
1064 let mut stream = TcpStream::connect(self.http_addr).unwrap();
1065 stream
1066 .write_all(
1067 format!(
1068 "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
1069 path, self.http_addr
1070 )
1071 .as_bytes(),
1072 )
1073 .unwrap();
1074 let mut raw = Vec::new();
1075 stream.read_to_end(&mut raw).unwrap();
1076 let split = raw
1077 .windows(4)
1078 .position(|w| w == b"\r\n\r\n")
1079 .expect("no header/body separator");
1080 let head = String::from_utf8_lossy(&raw[..split]).to_string();
1081 (head, raw[split + 4..].to_vec())
1082 }
1083 ```
1084
1085 - [ ] **Step 2: Write the canary test**
1086
1087 Create `tests/release_server_test.rs`:
1088
1089 ```rust
1090 mod common;
1091
1092 use common::ServerHarness;
1093
1094 #[test]
1095 fn ssh_client_interop_rejects_unknown_command() {
1096 let harness = ServerHarness::new("release-canary");
1097 harness.push_head();
1098
1099 let output = harness.ssh_exec("frobnicate");
1100 assert!(
1101 !output.status.success(),
1102 "unknown exec command must fail, got: {}",
1103 String::from_utf8_lossy(&output.stdout)
1104 );
1105 }
1106 ```
1107
1108 - [ ] **Step 3: Run the canary test**
1109
1110 Run: `cargo test --test release_server_test`
1111 Expected: PASS. This proves the OpenSSH client authenticates against the russh server and exec rejection produces a non-zero exit. If it hangs or auth fails, debug here before proceeding (check `ssh -v` output by hand against a running harness).
1112
1113 - [ ] **Step 4: Verify existing suites still pass**
1114
1115 Run: `cargo test --test server_behavior_test`
1116 Expected: PASS (harness refactor is behavior-preserving)
1117
1118 - [ ] **Step 5: Commit**
1119
1120 ```bash
1121 git add tests/common/mod.rs tests/release_server_test.rs
1122 git commit -m "Add SSH client support to server test harness"
1123 ```
1124
1125 ---### Task 6: SSH release operations end-to-end
1126
1127 TDD at the e2e level: write the SSH behavior tests first (they fail because the server rejects `collab-release`), then implement the handler dispatch.
1128
1129 **Files:**
1130 - Modify: `tests/release_server_test.rs` (tests first)
1131 - Modify: `src/server/ssh/session.rs` (handler implementation)
1132
1133 - [ ] **Step 1: Write the failing e2e tests**
1134
1135 Append to `tests/release_server_test.rs`:
1136
1137 ```rust
1138 use std::process::Output;
1139
1140 fn stdout(output: &Output) -> String {
1141 String::from_utf8_lossy(&output.stdout).to_string()
1142 }
1143
1144 fn assert_ssh_error(output: &Output, needle: &str) {
1145 assert!(!output.status.success(), "expected failure, got success");
1146 let all = format!(
1147 "{}{}",
1148 String::from_utf8_lossy(&output.stdout),
1149 String::from_utf8_lossy(&output.stderr)
1150 );
1151 assert!(all.contains(needle), "expected '{}' in output: {}", needle, all);
1152 }
1153
1154 #[test]
1155 fn upload_stores_file_with_checksum() {
1156 let harness = ServerHarness::new("release-upload");
1157 harness.push_head();
1158
1159 let content = b"fake tarball bytes";
1160 let output = harness.ssh_exec_with_stdin(
1161 "collab-release upload 'release-upload.git' 'v1.0.0' 'app.tar.gz'",
1162 content,
1163 );
1164 assert!(output.status.success(), "upload failed: {:?}", output);
1165
1166 // reply is "ok <sha256>"
1167 let reply = stdout(&output);
1168 let sha = reply.trim().strip_prefix("ok ").expect("reply not 'ok <sha>'");
1169
1170 use sha2::Digest;
1171 let expected: String = sha2::Sha256::digest(content)
1172 .iter()
1173 .map(|b| format!("{:02x}", b))
1174 .collect();
1175 assert_eq!(sha, expected);
1176
1177 let stored = harness
1178 .repos_dir()
1179 .join("release-upload.git/collab/releases/v1.0.0/app.tar.gz");
1180 assert_eq!(std::fs::read(&stored).unwrap(), content);
1181 assert!(stored.with_file_name("app.tar.gz.sha256").exists());
1182 }
1183
1184 #[test]
1185 fn duplicate_upload_needs_force() {
1186 let harness = ServerHarness::new("release-dup");
1187 harness.push_head();
1188 let cmd = "collab-release upload 'release-dup.git' 'v1' 'a.tar.gz'";
1189
1190 assert!(harness.ssh_exec_with_stdin(cmd, b"one").status.success());
1191 let dup = harness.ssh_exec_with_stdin(cmd, b"two");
1192 assert_ssh_error(&dup, "already exists");
1193
1194 let forced = harness.ssh_exec_with_stdin(
1195 "collab-release upload 'release-dup.git' 'v1' 'a.tar.gz' --force",
1196 b"two",
1197 );
1198 assert!(forced.status.success(), "forced upload failed: {:?}", forced);
1199 let stored = harness
1200 .repos_dir()
1201 .join("release-dup.git/collab/releases/v1/a.tar.gz");
1202 assert_eq!(std::fs::read(&stored).unwrap(), b"two");
1203 }
1204
1205 #[test]
1206 fn list_returns_json_index() {
1207 let harness = ServerHarness::new("release-list");
1208 harness.push_head();
1209
1210 harness.ssh_exec_with_stdin(
1211 "collab-release upload 'release-list.git' 'v1.0.0' 'a.tar.gz'",
1212 b"aaa",
1213 );
1214 let output = harness.ssh_exec("collab-release list 'release-list.git'");
1215 assert!(output.status.success());
1216
1217 let index: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap();
1218 let versions = index["versions"].as_array().unwrap();
1219 assert_eq!(versions.len(), 1);
1220 assert_eq!(versions[0]["version"], "v1.0.0");
1221 assert_eq!(versions[0]["files"][0]["name"], "a.tar.gz");
1222 assert_eq!(versions[0]["files"][0]["size"], 3);
1223 assert_eq!(versions[0]["files"][0]["sha256"].as_str().unwrap().len(), 64);
1224 }
1225
1226 #[test]
1227 fn delete_removes_file_then_version() {
1228 let harness = ServerHarness::new("release-del");
1229 harness.push_head();
1230
1231 harness.ssh_exec_with_stdin("collab-release upload 'release-del.git' 'v1' 'a.tar.gz'", b"a");
1232 harness.ssh_exec_with_stdin("collab-release upload 'release-del.git' 'v1' 'b.tar.gz'", b"b");
1233
1234 let releases = harness.repos_dir().join("release-del.git/collab/releases");
1235
1236 let del_file = harness.ssh_exec("collab-release delete 'release-del.git' 'v1' 'a.tar.gz'");
1237 assert!(del_file.status.success());
1238 assert!(!releases.join("v1/a.tar.gz").exists());
1239 assert!(releases.join("v1/b.tar.gz").exists());
1240
1241 let del_version = harness.ssh_exec("collab-release delete 'release-del.git' 'v1'");
1242 assert!(del_version.status.success());
1243 assert!(!releases.join("v1").exists());
1244
1245 let missing = harness.ssh_exec("collab-release delete 'release-del.git' 'v1'");
1246 assert_ssh_error(&missing, "not found");
1247 }
1248
1249 #[test]
1250 fn write_policy_gates_upload_and_delete_but_not_list() {
1251 let harness = ServerHarness::new("release-policy");
1252 harness.push_head();
1253 harness.write_repo_server_policy(
1254 "visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n",
1255 );
1256
1257 let upload = harness.ssh_exec_with_stdin(
1258 "collab-release upload 'release-policy.git' 'v1' 'a.tar.gz'",
1259 b"x",
1260 );
1261 assert!(!upload.status.success(), "upload must be denied");
1262
1263 let delete = harness.ssh_exec("collab-release delete 'release-policy.git' 'v1'");
1264 assert!(!delete.status.success(), "delete must be denied");
1265
1266 let list = harness.ssh_exec("collab-release list 'release-policy.git'");
1267 assert!(list.status.success(), "list must be allowed for readers");
1268 }
1269
1270 #[test]
1271 fn oversize_upload_rejected_without_partial_file() {
1272 let harness =
1273 ServerHarness::new_with_extra_config("release-size", "max_release_size = 16\n");
1274 harness.push_head();
1275
1276 let output = harness.ssh_exec_with_stdin(
1277 "collab-release upload 'release-size.git' 'v1' 'big.tar.gz'",
1278 &[0u8; 64],
1279 );
1280 assert_ssh_error(&output, "maximum release size");
1281
1282 let version_dir = harness.repos_dir().join("release-size.git/collab/releases/v1");
1283 assert!(!version_dir.join("big.tar.gz").exists());
1284 if version_dir.exists() {
1285 assert_eq!(std::fs::read_dir(&version_dir).unwrap().count(), 0);
1286 }
1287 }
1288
1289 #[test]
1290 fn invalid_names_and_unknown_repo_rejected() {
1291 let harness = ServerHarness::new("release-invalid");
1292 harness.push_head();
1293
1294 let traversal = harness.ssh_exec_with_stdin(
1295 "collab-release upload 'release-invalid.git' '../evil' 'a.tar.gz'",
1296 b"x",
1297 );
1298 assert!(!traversal.status.success());
1299
1300 let unknown = harness.ssh_exec_with_stdin(
1301 "collab-release upload 'nope.git' 'v1' 'a.tar.gz'",
1302 b"x",
1303 );
1304 assert!(!unknown.status.success());
1305 }
1306 ```
1307
1308 - [ ] **Step 2: Run tests to verify they fail**
1309
1310 Run: `cargo test --test release_server_test`
1311 Expected: canary PASSes; all new tests FAIL (server rejects `collab-release` as unknown command)
1312
1313 - [ ] **Step 3: Implement the SSH handler dispatch**
1314
1315 In `src/server/ssh/session.rs`:
1316
1317 1. Extend the handler struct and constructor:
1318
1319 ```rust
1320 pub struct SshHandler {
1321 config: Arc<SshServerConfig>,
1322 authenticated_principal: Option<String>,
1323 /// Sender for forwarding client data (stdin) to the spawned git subprocess.
1324 stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
1325 /// In-progress release upload, fed by data() and finalized on channel EOF.
1326 active_upload: Option<UploadSession>,
1327 }
1328
1329 struct UploadSession {
1330 channel: ChannelId,
1331 state: UploadState,
1332 }
1333
1334 enum UploadState {
1335 Active(crate::releases::ReleaseUpload),
1336 Failed(String),
1337 }
1338 ```
1339
1340 (add `active_upload: None` in `SshHandler::new`.)
1341
1342 2. Add a helper for error replies (free function near `run_git_command`):
1343
1344 ```rust
1345 fn reply_and_close(session: &mut Session, channel: ChannelId, message: &str, exit_code: u32) {
1346 if !message.is_empty() {
1347 session.data(channel, CryptoVec::from_slice(message.as_bytes()));
1348 }
1349 session.exit_status_request(channel, exit_code);
1350 session.eof(channel);
1351 session.close(channel);
1352 }
1353 ```
1354
1355 3. In `exec_request`, replace the `parse_git_command` call and its `None` arm with `parse_exec_command`, then dispatch. The existing git flow stays untouched; structure the method as:
1356
1357 ```rust
1358 let exec_cmd = match parse_exec_command(command_str) {
1359 Some(c) => c,
1360 None => {
1361 warn!("Rejected exec request: not an allowed command");
1362 reply_and_close(session, channel, "", 1);
1363 return Ok(());
1364 }
1365 };
1366
1367 let principal = match self.authenticated_principal.clone() {
1368 Some(principal) => principal,
1369 None => {
1370 warn!("Rejected exec request: not authenticated");
1371 reply_and_close(session, channel, "", 1);
1372 return Ok(());
1373 }
1374 };
1375
1376 let repo_arg = match &exec_cmd {
1377 ExecCommand::Git { repo, .. } => repo.clone(),
1378 ExecCommand::Release(rel) => rel.repo().to_string(),
1379 };
1380
1381 let resolved_path = match resolve_repo_path(&self.config.repos_dir, &repo_arg) {
1382 Some(p) => p,
1383 None => {
1384 warn!("Rejected exec request: path traversal detected");
1385 reply_and_close(session, channel, "error: invalid repo path\n", 1);
1386 return Ok(());
1387 }
1388 };
1389
1390 match exec_cmd {
1391 ExecCommand::Git { cmd: git_cmd, .. } => {
1392 // ... existing logic from `if resolved_path.exists()` through the
1393 // tokio::spawn of run_git_command, unchanged ...
1394 }
1395 ExecCommand::Release(rel) => {
1396 self.handle_release_command(channel, session, rel, &resolved_path, &principal);
1397 }
1398 }
1399
1400 Ok(())
1401 ```
1402
1403 4. Add the release handler as a method on `SshHandler` (inside the same `impl SshHandler` block as `new`, NOT the `Handler` trait impl):
1404
1405 ```rust
1406 fn handle_release_command(
1407 &mut self,
1408 channel: ChannelId,
1409 session: &mut Session,
1410 rel: ReleaseCmd,
1411 resolved_path: &Path,
1412 principal: &str,
1413 ) {
1414 let entry = match crate::repos::entry_for_path(resolved_path) {
1415 Some(entry) => entry,
1416 None => {
1417 warn!("Rejected release command: unknown repo {:?}", resolved_path);
1418 reply_and_close(session, channel, "error: repository not found\n", 1);
1419 return;
1420 }
1421 };
1422
1423 let authorized = match &rel {
1424 ReleaseCmd::List { .. } => entry.policy.allows_read(principal),
1425 ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => {
1426 entry.policy.allows_write(principal)
1427 }
1428 };
1429 if !authorized {
1430 warn!(
1431 "Rejected release command: principal {} not authorized on {:?}",
1432 principal, resolved_path
1433 );
1434 reply_and_close(session, channel, "error: not authorized\n", 1);
1435 return;
1436 }
1437
1438 let dir = crate::releases::releases_dir(&entry);
1439 match rel {
1440 ReleaseCmd::Upload {
1441 version,
1442 filename,
1443 force,
1444 ..
1445 } => match crate::releases::ReleaseUpload::begin(
1446 &dir,
1447 &version,
1448 &filename,
1449 force,
1450 self.config.max_release_size,
1451 ) {
1452 Ok(upload) => {
1453 self.active_upload = Some(UploadSession {
1454 channel,
1455 state: UploadState::Active(upload),
1456 });
1457 // Reply comes on channel EOF, once all bytes have arrived.
1458 }
1459 Err(e) => {
1460 reply_and_close(session, channel, &format!("error: {}\n", e), 1);
1461 }
1462 },
1463 ReleaseCmd::List { .. } => match crate::releases::list_releases(&dir) {
1464 Ok(index) => {
1465 let json = serde_json::to_string_pretty(&index)
1466 .unwrap_or_else(|_| "{\"versions\":[]}".to_string());
1467 reply_and_close(session, channel, &format!("{}\n", json), 0);
1468 }
1469 Err(e) => {
1470 reply_and_close(session, channel, &format!("error: {}\n", e), 1);
1471 }
1472 },
1473 ReleaseCmd::Delete {
1474 version, filename, ..
1475 } => match crate::releases::delete_release(&dir, &version, filename.as_deref()) {
1476 Ok(()) => {
1477 reply_and_close(session, channel, "deleted\n", 0);
1478 }
1479 Err(e) => {
1480 reply_and_close(session, channel, &format!("error: {}\n", e), 1);
1481 }
1482 },
1483 }
1484 }
1485 ```
1486
1487 5. Route upload bytes in `data()` (replace the method body):
1488
1489 ```rust
1490 async fn data(
1491 &mut self,
1492 channel: ChannelId,
1493 data: &[u8],
1494 _session: &mut Session,
1495 ) -> Result<(), Self::Error> {
1496 if let Some(upload) = self.active_upload.as_mut() {
1497 if upload.channel == channel {
1498 let state = std::mem::replace(
1499 &mut upload.state,
1500 UploadState::Failed(String::new()),
1501 );
1502 upload.state = match state {
1503 UploadState::Active(mut active) => match active.write(data) {
1504 Ok(()) => UploadState::Active(active),
1505 // Dropping `active` here discards the temp file.
1506 Err(e) => UploadState::Failed(e.to_string()),
1507 },
1508 failed => failed,
1509 };
1510 return Ok(());
1511 }
1512 }
1513 // Forward client data to the git subprocess's stdin
1514 if let Some(ref tx) = self.stdin_tx {
1515 if tx.send(data.to_vec()).await.is_err() {
1516 debug!("stdin channel closed, dropping data");
1517 }
1518 }
1519 Ok(())
1520 }
1521 ```
1522
1523 6. Add `channel_eof` to the `Handler` impl:
1524
1525 ```rust
1526 async fn channel_eof(
1527 &mut self,
1528 channel: ChannelId,
1529 session: &mut Session,
1530 ) -> Result<(), Self::Error> {
1531 // Close the git subprocess's stdin, if any.
1532 self.stdin_tx = None;
1533
1534 if let Some(upload) = self.active_upload.take() {
1535 if upload.channel != channel {
1536 self.active_upload = Some(upload);
1537 return Ok(());
1538 }
1539 match upload.state {
1540 UploadState::Active(active) => match active.finish() {
1541 Ok(sha) => reply_and_close(session, channel, &format!("ok {}\n", sha), 0),
1542 Err(e) => {
1543 reply_and_close(session, channel, &format!("error: {}\n", e), 1)
1544 }
1545 },
1546 UploadState::Failed(msg) => {
1547 reply_and_close(session, channel, &format!("error: {}\n", msg), 1)
1548 }
1549 }
1550 }
1551 Ok(())
1552 }
1553 ```
1554
1555 Imports to extend at the top of the file: `use super::…` stays; ensure `Path` is already imported (it is, line 1).
1556
1557 - [ ] **Step 4: Run the e2e tests**
1558
1559 Run: `cargo test --test release_server_test`
1560 Expected: PASS (all 8 tests)
1561
1562 Note: `stdin_tx = None` on EOF also affects git commands; verify no regression:
1563
1564 Run: `cargo test --test server_behavior_test --test collab_test`
1565 Expected: PASS
1566
1567 - [ ] **Step 5: Run the full suite**
1568
1569 Run: `cargo test`
1570 Expected: PASS
1571
1572 - [ ] **Step 6: Commit**
1573
1574 ```bash
1575 git add src/server/ssh/session.rs tests/release_server_test.rs
1576 git commit -m "Handle collab-release upload/list/delete over SSH"
1577 ```
1578
1579 ---
1580
1581 ### Task 7: HTTP releases page and downloads
1582
1583 **Files:**
1584 - Modify: `tests/release_server_test.rs` (tests first)
1585 - Create: `src/server/http/repo/releases.rs`
1586 - Create: `src/server/http/templates/releases.html`
1587 - Modify: `src/server/http/repo/mod.rs` (module + re-export)
1588 - Modify: `src/server/http/mod.rs` (routes)
1589 - Modify: `src/server/http/templates/repo_base.html` (nav link)
1590
1591 - [ ] **Step 1: Write the failing e2e tests**
1592
1593 Append to `tests/release_server_test.rs`:
1594
1595 ```rust
1596 #[test]
1597 fn http_releases_page_and_download() {
1598 let harness = ServerHarness::new("release-http");
1599 harness.push_head();
1600
1601 let content: Vec<u8> = (0u32..600).flat_map(|i| i.to_le_bytes()).collect(); // binary body
1602 harness.ssh_exec_with_stdin(
1603 "collab-release upload 'release-http.git' 'v2.0.0' 'app.tar.gz'",
1604 &content,
1605 );
1606
1607 let page = harness.get_ok("/release-http/releases");
1608 assert!(page.body.contains("v2.0.0"));
1609 assert!(page.body.contains("app.tar.gz"));
1610 assert!(page.body.contains("/release-http/releases/v2.0.0/app.tar.gz"));
1611
1612 let (head, body) = harness.get_bytes("/release-http/releases/v2.0.0/app.tar.gz");
1613 assert!(head.contains("200"), "download failed: {}", head);
1614 assert!(head.to_lowercase().contains("application/octet-stream"));
1615 assert!(head
1616 .to_lowercase()
1617 .contains(&format!("content-length: {}", content.len())));
1618 assert_eq!(body, content);
1619
1620 // checksum companion is downloadable as text
1621 let (sha_head, sha_body) =
1622 harness.get_bytes("/release-http/releases/v2.0.0/app.tar.gz.sha256");
1623 assert!(sha_head.contains("200"));
1624 assert!(String::from_utf8(sha_body).unwrap().contains("app.tar.gz"));
1625 }
1626
1627 #[test]
1628 fn http_release_download_404s() {
1629 let harness = ServerHarness::new("release-http-404");
1630 harness.push_head();
1631
1632 let missing = harness.get("/release-http-404/releases/v9/none.tar.gz");
1633 assert!(missing.status_line.contains("404"));
1634
1635 let traversal = harness.get("/release-http-404/releases/v9/..%2f..%2fconfig");
1636 assert!(!traversal.status_line.contains("200"));
1637
1638 let page = harness.get_ok("/release-http-404/releases");
1639 assert!(page.body.contains("No releases"));
1640 }
1641
1642 #[test]
1643 fn http_releases_respect_repo_policy() {
1644 let harness = ServerHarness::new("release-http-private");
1645 harness.push_head();
1646 harness.ssh_exec_with_stdin(
1647 "collab-release upload 'release-http-private.git' 'v1' 'a.tar.gz'",
1648 b"secret",
1649 );
1650 harness.write_repo_server_policy(
1651 "visibility = \"private\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n[access]\nread = [\"*\"]\nwrite = [\"*\"]\n",
1652 );
1653
1654 let page = harness.get("/release-http-private/releases");
1655 assert!(page.status_line.contains("404"));
1656
1657 let (head, _) = harness.get_bytes("/release-http-private/releases/v1/a.tar.gz");
1658 assert!(head.contains("404"), "private artifact must not be served: {}", head);
1659 }
1660 ```
1661
1662 Note on the `.sha256` filename: `validate_name` allows it (starts alphanumeric, dots allowed), so downloads of companions need no special-casing beyond Content-Type.
1663
1664 - [ ] **Step 2: Run tests to verify they fail**
1665
1666 Run: `cargo test --test release_server_test http_`
1667 Expected: FAIL (404 — routes don't exist; `get_ok` panics on the page test)
1668
1669 - [ ] **Step 3: Implement handlers**
1670
1671 Create `src/server/http/repo/releases.rs`:
1672
1673 ```rust
1674 use std::sync::Arc;
1675
1676 use axum::extract::{Path, State};
1677 use axum::http::{header, HeaderValue, StatusCode};
1678 use axum::response::{IntoResponse, Response};
1679 use tokio_util::io::ReaderStream;
1680
1681 use super::{collab_counts, open_repo, AppState};
1682 use crate::releases::{list_releases, releases_dir, ReleaseVersion};
1683 use git_collab::release::validate_name;
1684
1685 #[derive(askama::Template, askama_web::WebTemplate)]
1686 #[template(path = "releases.html")]
1687 pub struct ReleasesTemplate {
1688 pub site_title: String,
1689 pub repo_name: String,
1690 pub active_section: String,
1691 pub open_patches: usize,
1692 pub open_issues: usize,
1693 pub versions: Vec<ReleaseVersion>,
1694 }
1695
1696 pub async fn releases(
1697 Path(repo_name): Path<String>,
1698 State(state): State<Arc<AppState>>,
1699 ) -> Response {
1700 let (entry, repo) = match open_repo(&state, &repo_name) {
1701 Ok(pair) => pair,
1702 Err(resp) => return resp,
1703 };
1704 let (open_patches, open_issues) = collab_counts(&repo);
1705 let versions = list_releases(&releases_dir(&entry))
1706 .map(|index| index.versions)
1707 .unwrap_or_default();
1708
1709 ReleasesTemplate {
1710 site_title: state.site_title.clone(),
1711 repo_name,
1712 active_section: "releases".to_string(),
1713 open_patches,
1714 open_issues,
1715 versions,
1716 }
1717 .into_response()
1718 }
1719
1720 pub async fn release_download(
1721 Path((repo_name, version, filename)): Path<(String, String, String)>,
1722 State(state): State<Arc<AppState>>,
1723 ) -> Response {
1724 let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
1725 Some(e) => e,
1726 None => return plain_404(),
1727 };
1728 // Downloads are data distribution, like clone.
1729 if !entry.policy.allows_anonymous_http() {
1730 return plain_404();
1731 }
1732 // A ".sha256"-suffixed name is "<artifact>.sha256"; validate the artifact part.
1733 let base = filename.strip_suffix(".sha256").unwrap_or(&filename);
1734 if !validate_name(&version) || !validate_name(base) {
1735 return plain_404();
1736 }
1737
1738 let path = releases_dir(&entry).join(&version).join(&filename);
1739 let file = match tokio::fs::File::open(&path).await {
1740 Ok(f) => f,
1741 Err(_) => return plain_404(),
1742 };
1743 let len = match file.metadata().await {
1744 Ok(m) if m.is_file() => m.len(),
1745 _ => return plain_404(),
1746 };
1747
1748 let content_type = if filename.ends_with(".sha256") {
1749 "text/plain; charset=utf-8"
1750 } else {
1751 "application/octet-stream"
1752 };
1753
1754 let mut response = Response::new(axum::body::Body::from_stream(ReaderStream::new(file)));
1755 let headers = response.headers_mut();
1756 headers.insert(
1757 header::CONTENT_TYPE,
1758 HeaderValue::from_static(content_type),
1759 );
1760 if let Ok(value) = HeaderValue::from_str(&len.to_string()) {
1761 headers.insert(header::CONTENT_LENGTH, value);
1762 }
1763 response
1764 }
1765
1766 fn plain_404() -> Response {
1767 (StatusCode::NOT_FOUND, "Not found").into_response()
1768 }
1769 ```
1770
1771 (The HTML 404 for the releases page comes from `open_repo` internally; downloads use the plain-text 404 above, matching `git_http.rs`.)
1772
1773 Create `src/server/http/templates/releases.html`:
1774
1775 ```html
1776 {% extends "repo_base.html" %}
1777
1778 {% block title %}{{ repo_name }} · releases · {{ site_title }}{% endblock %}
1779
1780 {% block content %}
1781 <h2>Releases</h2>
1782 {% if versions.is_empty() %}
1783 <p>No releases.</p>
1784 {% endif %}
1785 {% for v in versions %}
1786 <section>
1787 <h3>{{ v.version }}</h3>
1788 <p>{{ v.published }}</p>
1789 <ul>
1790 {% for f in v.files %}
1791 <li>
1792 <a href="/{{ repo_name }}/releases/{{ v.version }}/{{ f.name }}">{{ f.name }}</a>
1793 ({{ f.size }} bytes)
1794 <code>{{ f.sha256 }}</code>
1795 </li>
1796 {% endfor %}
1797 </ul>
1798 </section>
1799 {% endfor %}
1800 {% endblock %}
1801 ```
1802
1803 Before finalizing, check how an existing template (e.g. `patches.html`) declares its title block and match that convention exactly; if `base.html` has no `title` block, drop that block here.
1804
1805 In `src/server/http/repo/mod.rs`, add:
1806
1807 ```rust
1808 mod releases;
1809 pub use releases::{release_download, releases};
1810 ```
1811
1812 In `src/server/http/mod.rs`, add routes (after the `/{repo_name}/issues/{id}` route, before the git routes):
1813
1814 ```rust
1815 .route("/{repo_name}/releases", axum::routing::get(repo::releases))
1816 .route(
1817 "/{repo_name}/releases/{version}/{filename}",
1818 axum::routing::get(repo::release_download),
1819 )
1820 ```
1821
1822 In `src/server/http/templates/repo_base.html`, add to the nav after the issues link:
1823
1824 ```html
1825 <a href="/{{ repo_name }}/releases"{% if active_section == "releases" %} class="active"{% endif %}>releases</a>
1826 ```
1827
1828 - [ ] **Step 4: Run tests to verify they pass**
1829
1830 Run: `cargo test --test release_server_test`
1831 Expected: PASS (all 11 tests)
1832
1833 Run: `cargo test --test server_behavior_test`
1834 Expected: PASS (nav change must not break existing page tests)
1835
1836 - [ ] **Step 5: Commit**
1837
1838 ```bash
1839 git add src/server/http tests/release_server_test.rs
1840 git commit -m "Serve release listings and downloads over HTTP"
1841 ```
1842
1843 ---
1844
1845 ### Task 8: CLI `git-collab release` commands
1846
1847 **Files:**
1848 - Modify: `src/release.rs` (remote parsing + command execution)
1849 - Modify: `src/cli.rs` (subcommand)
1850 - Modify: `src/lib.rs` (dispatch)
1851 - Create: `tests/release_cli_test.rs`
1852
1853 - [ ] **Step 1: Write failing unit tests for remote URL parsing**
1854
1855 Append to `src/release.rs` (inside the existing `tests` module):
1856
1857 ```rust
1858 #[test]
1859 fn parse_ssh_url_full() {
1860 let r = parse_ssh_remote("ssh://git@example.com:2222/myrepo.git").unwrap();
1861 assert_eq!(r.user.as_deref(), Some("git"));
1862 assert_eq!(r.host, "example.com");
1863 assert_eq!(r.port, Some(2222));
1864 assert_eq!(r.path, "myrepo.git");
1865 }
1866
1867 #[test]
1868 fn parse_ssh_url_minimal() {
1869 let r = parse_ssh_remote("ssh://example.com/org/repo.git").unwrap();
1870 assert_eq!(r.user, None);
1871 assert_eq!(r.port, None);
1872 assert_eq!(r.path, "org/repo.git");
1873 }
1874
1875 #[test]
1876 fn parse_scp_style() {
1877 let r = parse_ssh_remote("git@example.com:myrepo.git").unwrap();
1878 assert_eq!(r.user.as_deref(), Some("git"));
1879 assert_eq!(r.host, "example.com");
1880 assert_eq!(r.port, None);
1881 assert_eq!(r.path, "myrepo.git");
1882 }
1883
1884 #[test]
1885 fn parse_rejects_non_ssh() {
1886 assert!(parse_ssh_remote("https://example.com/repo.git").is_none());
1887 assert!(parse_ssh_remote("/srv/git/repo.git").is_none());
1888 assert!(parse_ssh_remote("../relative/path").is_none());
1889 assert!(parse_ssh_remote("file:///srv/git/repo.git").is_none());
1890 }
1891 ```
1892
1893 - [ ] **Step 2: Run tests to verify they fail**
1894
1895 Run: `cargo test --lib release::`
1896 Expected: FAIL to compile (no `parse_ssh_remote`)
1897
1898 - [ ] **Step 3: Implement the client module**
1899
1900 Add to `src/release.rs`:
1901
1902 ```rust
1903 use std::path::PathBuf;
1904 use std::process::{Command, Output, Stdio};
1905
1906 use git2::Repository;
1907
1908 use crate::error::Error;
1909
1910 #[derive(Debug, PartialEq, Eq)]
1911 pub struct SshRemote {
1912 pub user: Option<String>,
1913 pub host: String,
1914 pub port: Option<u16>,
1915 pub path: String,
1916 }
1917
1918 /// Parse `ssh://[user@]host[:port]/path` or scp-style `[user@]host:path`.
1919 pub fn parse_ssh_remote(url: &str) -> Option<SshRemote> {
1920 if let Some(rest) = url.strip_prefix("ssh://") {
1921 let (authority, path) = rest.split_once('/')?;
1922 let (user, hostport) = split_user(authority);
1923 let (host, port) = match hostport.rsplit_once(':') {
1924 Some((h, p)) => (h.to_string(), Some(p.parse().ok()?)),
1925 None => (hostport.to_string(), None),
1926 };
1927 if host.is_empty() || path.is_empty() {
1928 return None;
1929 }
1930 return Some(SshRemote {
1931 user,
1932 host,
1933 port,
1934 path: path.to_string(),
1935 });
1936 }
1937 if url.contains("://") {
1938 return None;
1939 }
1940 // scp-style: [user@]host:path — but not a local path
1941 let (authority, path) = url.split_once(':')?;
1942 if authority.is_empty() || path.is_empty() || authority.contains('/') {
1943 return None;
1944 }
1945 let (user, host) = split_user(authority);
1946 Some(SshRemote {
1947 user,
1948 host: host.to_string(),
1949 port: None,
1950 path: path.to_string(),
1951 })
1952 }
1953
1954 fn split_user(authority: &str) -> (Option<String>, &str) {
1955 match authority.split_once('@') {
1956 Some((user, host)) => (Some(user.to_string()), host),
1957 None => (None, authority),
1958 }
1959 }
1960
1961 fn ssh_remote(repo: &Repository, remote_name: &str) -> Result<SshRemote, Error> {
1962 let remote = repo
1963 .find_remote(remote_name)
1964 .map_err(|_| Error::Cmd(format!("remote '{}' not found", remote_name)))?;
1965 let url = remote
1966 .url()
1967 .ok_or_else(|| Error::Cmd(format!("remote '{}' has no URL", remote_name)))?;
1968 parse_ssh_remote(url).ok_or_else(|| {
1969 Error::Cmd(format!(
1970 "remote '{}' ({}) is not an SSH remote — releases need an ssh:// or user@host: remote",
1971 remote_name, url
1972 ))
1973 })
1974 }
1975
1976 /// Build the ssh invocation for a remote, honoring GIT_COLLAB_SSH_COMMAND
1977 /// (like git's GIT_SSH_COMMAND: extra words become leading arguments).
1978 fn ssh_command(remote: &SshRemote) -> Command {
1979 let base = std::env::var("GIT_COLLAB_SSH_COMMAND").unwrap_or_else(|_| "ssh".to_string());
1980 let mut parts = base.split_whitespace();
1981 let mut cmd = Command::new(parts.next().unwrap_or("ssh").to_string());
1982 for part in parts {
1983 cmd.arg(part);
1984 }
1985 if let Some(port) = remote.port {
1986 cmd.arg("-p").arg(port.to_string());
1987 }
1988 match &remote.user {
1989 Some(user) => cmd.arg(format!("{}@{}", user, remote.host)),
1990 None => cmd.arg(&remote.host),
1991 };
1992 cmd
1993 }
1994
1995 fn run_remote(
1996 remote: &SshRemote,
1997 remote_cmd: &str,
1998 stdin: Stdio,
1999 ) -> Result<Output, Error> {
2000 let output = ssh_command(remote)
2001 .arg(remote_cmd)
2002 .stdin(stdin)
2003 .output()
2004 .map_err(|e| Error::Cmd(format!("failed to run ssh: {}", e)))?;
2005 if !output.status.success() {
2006 let msg = format!(
2007 "{}{}",
2008 String::from_utf8_lossy(&output.stdout).trim(),
2009 String::from_utf8_lossy(&output.stderr).trim()
2010 );
2011 return Err(Error::Cmd(if msg.is_empty() {
2012 "server rejected the command".to_string()
2013 } else {
2014 msg
2015 }));
2016 }
2017 Ok(output)
2018 }
2019
2020 pub fn publish(
2021 repo: &Repository,
2022 remote_name: &str,
2023 version: &str,
2024 files: &[PathBuf],
2025 force: bool,
2026 ) -> Result<(), Error> {
2027 if !validate_name(version) {
2028 return Err(Error::Cmd(format!("invalid version name: {}", version)));
2029 }
2030 let remote = ssh_remote(repo, remote_name)?;
2031 for file in files {
2032 let filename = file
2033 .file_name()
2034 .and_then(|n| n.to_str())
2035 .ok_or_else(|| Error::Cmd(format!("invalid file path: {}", file.display())))?
2036 .to_string();
2037 if !validate_name(&filename) {
2038 return Err(Error::Cmd(format!("invalid filename: {}", filename)));
2039 }
2040 let handle = std::fs::File::open(file)
2041 .map_err(|e| Error::Cmd(format!("cannot open {}: {}", file.display(), e)))?;
2042 let mut remote_cmd = format!(
2043 "collab-release upload '{}' '{}' '{}'",
2044 remote.path, version, filename
2045 );
2046 if force {
2047 remote_cmd.push_str(" --force");
2048 }
2049 let output = run_remote(&remote, &remote_cmd, Stdio::from(handle))?;
2050 let stdout = String::from_utf8_lossy(&output.stdout);
2051 let sha = stdout.trim().strip_prefix("ok ").unwrap_or("").to_string();
2052 println!("Published {}/{} (sha256 {})", version, filename, sha);
2053 }
2054 Ok(())
2055 }
2056
2057 pub fn list(repo: &Repository, remote_name: &str, json: bool) -> Result<(), Error> {
2058 let remote = ssh_remote(repo, remote_name)?;
2059 let remote_cmd = format!("collab-release list '{}'", remote.path);
2060 let output = run_remote(&remote, &remote_cmd, Stdio::null())?;
2061 let stdout = String::from_utf8_lossy(&output.stdout);
2062 if json {
2063 print!("{}", stdout);
2064 return Ok(());
2065 }
2066 let index: serde_json::Value = serde_json::from_str(stdout.trim())?;
2067 let versions = index["versions"].as_array().cloned().unwrap_or_default();
2068 if versions.is_empty() {
2069 println!("No releases.");
2070 return Ok(());
2071 }
2072 for v in &versions {
2073 println!(
2074 "{} ({})",
2075 v["version"].as_str().unwrap_or("?"),
2076 v["published"].as_str().unwrap_or("?")
2077 );
2078 for f in v["files"].as_array().cloned().unwrap_or_default() {
2079 println!(
2080 " {} {} bytes sha256:{}",
2081 f["name"].as_str().unwrap_or("?"),
2082 f["size"].as_u64().unwrap_or(0),
2083 f["sha256"].as_str().unwrap_or("?")
2084 );
2085 }
2086 }
2087 Ok(())
2088 }
2089
2090 pub fn delete(
2091 repo: &Repository,
2092 remote_name: &str,
2093 version: &str,
2094 filename: Option<&str>,
2095 ) -> Result<(), Error> {
2096 let remote = ssh_remote(repo, remote_name)?;
2097 let mut remote_cmd = format!("collab-release delete '{}' '{}'", remote.path, version);
2098 if let Some(name) = filename {
2099 remote_cmd.push_str(&format!(" '{}'", name));
2100 }
2101 run_remote(&remote, &remote_cmd, Stdio::null())?;
2102 match filename {
2103 Some(name) => println!("Deleted {}/{}", version, name),
2104 None => println!("Deleted {}", version),
2105 }
2106 Ok(())
2107 }
2108 ```
2109
2110 - [ ] **Step 4: Run unit tests**
2111
2112 Run: `cargo test --lib release::`
2113 Expected: PASS (7 tests)
2114
2115 - [ ] **Step 5: Add the CLI subcommand and dispatch**
2116
2117 In `src/cli.rs`, add to `enum Commands` (after `Patch(PatchCmd)`):
2118
2119 ```rust
2120 /// Manage release artifacts on the server
2121 #[command(subcommand)]
2122 Release(ReleaseCmd),
2123 ```
2124
2125 and add the enum (after `PatchCmd`):
2126
2127 ```rust
2128 #[derive(Subcommand)]
2129 pub enum ReleaseCmd {
2130 /// Upload files to a release version on the server
2131 Publish {
2132 /// Release version (e.g. v1.2.0)
2133 version: String,
2134 /// Files to upload
2135 #[arg(required = true)]
2136 files: Vec<std::path::PathBuf>,
2137 /// Replace files that already exist in this version
2138 #[arg(long)]
2139 force: bool,
2140 /// Remote name
2141 #[arg(long, default_value = "origin")]
2142 remote: String,
2143 },
2144 /// List releases on the server
2145 List {
2146 /// Output as JSON
2147 #[arg(long)]
2148 json: bool,
2149 /// Remote name
2150 #[arg(long, default_value = "origin")]
2151 remote: String,
2152 },
2153 /// Delete a release version, or a single file from it
2154 Delete {
2155 /// Release version
2156 version: String,
2157 /// Filename (omit to delete the whole version)
2158 filename: Option<String>,
2159 /// Remote name
2160 #[arg(long, default_value = "origin")]
2161 remote: String,
2162 },
2163 }
2164 ```
2165
2166 In `src/lib.rs`:
2167 - extend the `use cli::…` import: `use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd, ReleaseCmd};`
2168 - add to the `match cli.command` in `run()` (after the `Commands::Patch(cmd)` arm):
2169
2170 ```rust
2171 Commands::Release(cmd) => match cmd {
2172 ReleaseCmd::Publish {
2173 version,
2174 files,
2175 force,
2176 remote,
2177 } => release::publish(repo, &remote, &version, &files, force),
2178 ReleaseCmd::List { json, remote } => release::list(repo, &remote, json),
2179 ReleaseCmd::Delete {
2180 version,
2181 filename,
2182 remote,
2183 } => release::delete(repo, &remote, &version, filename.as_deref()),
2184 },
2185 ```
2186
2187 Run: `cargo build`
2188 Expected: compiles
2189
2190 - [ ] **Step 6: Write CLI end-to-end tests**
2191
2192 Create `tests/release_cli_test.rs`:
2193
2194 ```rust
2195 mod common;
2196
2197 use std::process::Output;
2198
2199 use common::ServerHarness;
2200
2201 /// Run `git-collab release …` in the harness work repo against the harness SSH server.
2202 fn release_cmd(harness: &ServerHarness, args: &[&str]) -> Output {
2203 let mut cmd = harness.work_repo().cli_command();
2204 cmd.env("GIT_COLLAB_SSH_COMMAND", harness.ssh_command_string());
2205 cmd.args(["release"]).args(args);
2206 cmd.output().expect("failed to run git-collab release")
2207 }
2208
2209 fn setup(name: &str) -> ServerHarness {
2210 let harness = ServerHarness::new(name);
2211 harness.push_head();
2212 let url = harness.repo_ssh_url();
2213 harness.work_repo().git(&["remote", "add", "srv", &url]);
2214 harness
2215 }
2216
2217 #[test]
2218 fn publish_list_delete_roundtrip() {
2219 let harness = setup("cli-roundtrip");
2220 let tarball = harness.work_repo().dir.path().join("app.tar.gz");
2221 std::fs::write(&tarball, b"cli release bytes").unwrap();
2222
2223 let publish = release_cmd(
2224 &harness,
2225 &["publish", "v1.0.0", tarball.to_str().unwrap(), "--remote", "srv"],
2226 );
2227 assert!(
2228 publish.status.success(),
2229 "publish failed: {}{}",
2230 String::from_utf8_lossy(&publish.stdout),
2231 String::from_utf8_lossy(&publish.stderr)
2232 );
2233 let out = String::from_utf8_lossy(&publish.stdout);
2234 assert!(out.contains("Published v1.0.0/app.tar.gz"));
2235
2236 let list = release_cmd(&harness, &["list", "--remote", "srv"]);
2237 assert!(list.status.success());
2238 assert!(String::from_utf8_lossy(&list.stdout).contains("v1.0.0"));
2239
2240 let list_json = release_cmd(&harness, &["list", "--json", "--remote", "srv"]);
2241 let index: serde_json::Value =
2242 serde_json::from_slice(&list_json.stdout).expect("list --json not valid JSON");
2243 assert_eq!(index["versions"][0]["files"][0]["name"], "app.tar.gz");
2244
2245 let delete = release_cmd(&harness, &["delete", "v1.0.0", "--remote", "srv"]);
2246 assert!(delete.status.success());
2247 let after: serde_json::Value =
2248 serde_json::from_slice(&release_cmd(&harness, &["list", "--json", "--remote", "srv"]).stdout)
2249 .unwrap();
2250 assert_eq!(after["versions"].as_array().unwrap().len(), 0);
2251 }
2252
2253 #[test]
2254 fn duplicate_publish_needs_force_flag() {
2255 let harness = setup("cli-force");
2256 let tarball = harness.work_repo().dir.path().join("a.tar.gz");
2257 std::fs::write(&tarball, b"one").unwrap();
2258 let path = tarball.to_str().unwrap();
2259
2260 assert!(release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"])
2261 .status
2262 .success());
2263
2264 let dup = release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]);
2265 assert!(!dup.status.success());
2266 assert!(String::from_utf8_lossy(&dup.stderr).contains("already exists"));
2267
2268 std::fs::write(&tarball, b"two").unwrap();
2269 let forced = release_cmd(
2270 &harness,
2271 &["publish", "v1", path, "--force", "--remote", "srv"],
2272 );
2273 assert!(forced.status.success());
2274 }
2275
2276 #[test]
2277 fn non_ssh_remote_is_a_clear_error() {
2278 let harness = setup("cli-bad-remote");
2279 let tarball = harness.work_repo().dir.path().join("a.tar.gz");
2280 std::fs::write(&tarball, b"x").unwrap();
2281
2282 // "origin" is a local filesystem path in the harness
2283 let output = release_cmd(
2284 &harness,
2285 &["publish", "v1", tarball.to_str().unwrap(), "--remote", "origin"],
2286 );
2287 assert!(!output.status.success());
2288 assert!(String::from_utf8_lossy(&output.stderr).contains("not an SSH remote"));
2289 }
2290
2291 #[test]
2292 fn invalid_version_rejected_client_side() {
2293 let harness = setup("cli-bad-version");
2294 let tarball = harness.work_repo().dir.path().join("a.tar.gz");
2295 std::fs::write(&tarball, b"x").unwrap();
2296
2297 let output = release_cmd(
2298 &harness,
2299 &["publish", "../evil", tarball.to_str().unwrap(), "--remote", "srv"],
2300 );
2301 assert!(!output.status.success());
2302 assert!(String::from_utf8_lossy(&output.stderr).contains("invalid version"));
2303 }
2304 ```
2305
2306 - [ ] **Step 7: Run the CLI tests**
2307
2308 Run: `cargo test --test release_cli_test`
2309 Expected: PASS (4 tests)
2310
2311 - [ ] **Step 8: Commit**
2312
2313 ```bash
2314 git add src/release.rs src/cli.rs src/lib.rs tests/release_cli_test.rs
2315 git commit -m "Add git-collab release publish/list/delete CLI"
2316 ```
2317
2318 ---
2319
2320 ### Task 9: Final verification
2321
2322 - [ ] **Step 1: Full test suite**
2323
2324 Run: `cargo test`
2325 Expected: PASS, zero failures
2326
2327 - [ ] **Step 2: Clippy**
2328
2329 Run: `cargo clippy --all-targets`
2330 Expected: no warnings. Fix anything it flags (likely candidates: `format!` in `push_str`, needless clones in the exec dispatch).
2331
2332 - [ ] **Step 3: Commit any lint fixes**
2333
2334 ```bash
2335 git add -A
2336 git commit -m "Fix clippy lints in release packages feature"
2337 ```
2338
2339 (skip if nothing changed)
docs/superpowers/specs/2026-08-08-release-packages-design.md
Old New
@@ -0,0 +1,167 @@
1 # Release Packages — Design
2
3 Date: 2026-08-08
4 Status: Approved (brainstorm with alex)
5
6 ## Purpose
7
8 Let maintainers and agents upload release artifacts (e.g. `tar.gz` builds) to the
9 git-collab server and distribute them over stable HTTP URLs. No package registry
10 semantics — just versioned files with checksums.
11
12 ## Decisions (from brainstorm)
13
14 - **Upload transport:** SSH, via a new exec verb — reuses existing key auth and
15 per-repo write policy. No new HTTP auth surface.
16 - **Versioning:** freeform version strings (e.g. `v1.2.0`); not tied to git tags.
17 - **Downloads:** public HTTP URLs plus a releases page in the web UI, gated by
18 existing repo policies.
19 - **Release contents:** one or more files per version, each with a server-computed
20 SHA-256. No release notes (can be added later).
21 - **Mutability:** re-publishing an existing version/filename is rejected unless
22 `--force`; `release delete` removes a file or a whole version.
23
24 ## CLI (client side)
25
26 ```
27 git-collab release publish <version> <file>... [--force] [--remote <name>]
28 git-collab release list [--json] [--remote <name>]
29 git-collab release delete <version> [<filename>] [--remote <name>]
30 ```
31
32 - The server is derived from the repo's SSH remote URL (`origin` by default,
33 overridable with `--remote`). Both `ssh://user@host[:port]/path` and scp-style
34 `user@host:path` URLs are supported. A non-SSH remote is an error.
35 - The client shells out to `ssh` (as git itself does), piping the file to the
36 remote command's stdin for uploads.
37 - `publish` with multiple files uploads them sequentially and stops on first
38 error, reporting which files succeeded.
39 - `list --json` emits machine-readable output for agent scripting.
40 - Exit codes: 0 on success, non-zero with the server's `error:` message on
41 stderr otherwise.
42
43 ## Wire protocol (SSH exec commands)
44
45 The SSH server's exec allowlist (currently `git-upload-pack`,
46 `git-receive-pack`) gains a `collab-release` verb family:
47
48 ```
49 collab-release upload '<repo>' '<version>' '<filename>' [--force]
50 collab-release list '<repo>'
51 collab-release delete '<repo>' '<version>' ['<filename>']
52 ```
53
54 - **upload:** reads the file bytes from stdin until EOF. On success prints
55 `ok <sha256>\n` and exits 0. On failure prints `error: <reason>\n` and exits
56 non-zero. If the target file already exists and `--force` is absent, fails
57 with `error: <version>/<filename> already exists (use --force to replace)`.
58 - **list:** prints a JSON document to stdout:
59
60 ```json
61 {
62 "versions": [
63 {
64 "version": "v1.2.0",
65 "published": "2026-08-08T12:00:00Z",
66 "files": [
67 {"name": "app-x86_64.tar.gz", "size": 1048576, "sha256": "<hex>"}
68 ]
69 }
70 ]
71 }
72 ```
73
74 Versions are ordered newest-first by publish time (directory mtime).
75 - **delete:** with a filename, removes that file (and its `.sha256`); without,
76 removes the whole version directory. Deleting the last file of a version
77 removes the version directory. Missing targets are an error.
78
79 ### Authorization
80
81 - `upload` and `delete` require the repo's existing **write** policy
82 (same check as `git-receive-pack`).
83 - `list` requires **read** (same check as `git-upload-pack`).
84
85 ### Validation
86
87 - `<version>` and `<filename>` must match `^[A-Za-z0-9][A-Za-z0-9._-]*$`
88 (ASCII only, no leading dot, no slashes — rules out path traversal), max
89 128 bytes each.
90 - Uploads are capped by a server config option `max_release_size` (bytes,
91 default 1 GiB). An oversize stream is aborted, the temp file removed, and
92 `error: file exceeds maximum release size` returned.
93 - Unlike git commands, the repo argument is the repo *name* resolved through
94 the existing repo discovery (`repos.rs`), never a raw filesystem path.
95
96 ### Atomicity
97
98 The server streams stdin to a temp file inside the repo's `collab/` dir
99 (same filesystem), computes SHA-256 while streaming, then atomically renames
100 the file and writes `<filename>.sha256` into place. A dropped connection or
101 failed validation never leaves a partial artifact visible. With `--force`,
102 the rename replaces the old file atomically.
103
104 ## Server storage
105
106 ```
107 {repo}.git/collab/releases/{version}/{filename}
108 {repo}.git/collab/releases/{version}/{filename}.sha256
109 ```
110
111 Same `collab/` directory that already holds `trusted-keys`. No database or
112 manifest file — the filesystem is the release index. `.sha256` files contain
113 `<hex> <filename>\n` (sha256sum-compatible), and are excluded from release
114 listings and HTTP directory listings as assets in their own right (they remain
115 individually downloadable).
116
117 ## HTTP (distribution)
118
119 Two new routes in `src/server/http/mod.rs`:
120
121 - `GET /{repo}/releases` — HTML page listing versions newest-first, each with
122 its files, sizes, and SHA-256s, linking to downloads. Uses the existing
123 templates/layout. Gated by the same **UI anonymous** policy as the other
124 repo pages.
125 - `GET /{repo}/releases/{version}/{filename}` — streams the artifact with
126 `Content-Length` and `application/octet-stream` (`.sha256` companions are
127 served as `text/plain`). Gated by the **anonymous_clone** HTTP policy,
128 since downloads are data distribution like clone.
129
130 Path segments are validated with the same rules as upload before touching the
131 filesystem. Unknown repo, version, or file → 404.
132
133 ## Error handling summary
134
135 | Condition | Result |
136 | --- | --- |
137 | Invalid version/filename | `error: invalid name` (SSH) / 404 (HTTP) |
138 | Unknown repo | error / 404 |
139 | No write access (upload/delete) | error, exit non-zero |
140 | No read access (list) | error, exit non-zero |
141 | File exists, no `--force` | error naming the conflict |
142 | Oversize upload | aborted, temp cleaned up, error |
143 | Dropped connection mid-upload | temp cleaned up, nothing visible |
144
145 ## Testing
146
147 TDD throughout (tests first):
148
149 - **Unit:** `collab-release` command parsing (quoting, `--force`, arg counts),
150 name validation (traversal attempts, dotfiles, non-ASCII, overlength).
151 - **End-to-end** (existing `tests/server_behavior_test.rs` style):
152 - publish over SSH → files + checksums on disk, `ok <sha256>` matches.
153 - publish duplicate without `--force` fails; with `--force` replaces.
154 - list over SSH returns correct JSON ordering and metadata.
155 - delete file / delete version / delete last file removes version dir.
156 - HTTP download round-trips bytes with correct headers; releases page lists
157 the version; policy-restricted repo denies anonymous download and page.
158 - oversize upload rejected, no partial file left behind.
159 - **CLI:** publish/list/delete against a test server; `--json` output shape.
160
161 ## Out of scope (deliberate)
162
163 - Release notes / markdown descriptions.
164 - Tying releases to git tags.
165 - HTTP upload endpoint or token auth.
166 - Signing of artifacts (checksums only; signatures can ship as ordinary
167 release files, e.g. `app.tar.gz.sig`).
src/server/http/git_http.rs
Old New
@@ -44,6 +44,14 @@ pub async fn info_refs(
44 } 44 }
45 }; 45 };
46 46
47 if !entry.policy.allows_anonymous_http() {
48 return (
49 StatusCode::NOT_FOUND,
50 format!("Repository '{}' not found.", repo_name),
51 )
52 .into_response();
53 }
54
47 let git_dir = if entry.bare { 55 let git_dir = if entry.bare {
48 entry.path.clone() 56 entry.path.clone()
49 } else { 57 } else {
@@ -120,6 +128,14 @@ pub async fn upload_pack(
120 } 128 }
121 }; 129 };
122 130
131 if !entry.policy.allows_anonymous_http() {
132 return (
133 StatusCode::NOT_FOUND,
134 format!("Repository '{}' not found.", repo_name),
135 )
136 .into_response();
137 }
138
123 let git_dir = if entry.bare { 139 let git_dir = if entry.bare {
124 entry.path.clone() 140 entry.path.clone()
125 } else { 141 } else {
src/server/http/repo/mod.rs
Old New
@@ -174,6 +174,13 @@ fn open_repo(
174 } 174 }
175 }; 175 };
176 176
177 if !entry.policy.allows_anonymous_ui() {
178 return Err(not_found(
179 state,
180 format!("Repository '{}' not found.", repo_name),
181 ));
182 }
183
177 let repo = match crate::repos::open(&entry) { 184 let repo = match crate::repos::open(&entry) {
178 Ok(r) => r, 185 Ok(r) => r,
179 Err(_) => return Err(internal_error(state, "Failed to open repository.")), 186 Err(_) => return Err(internal_error(state, "Failed to open repository.")),
src/server/http/repo_list.rs
Old New
@@ -34,6 +34,7 @@ fn build_repo_list(state: &AppState) -> Vec<RepoListItem> {
34 34
35 entries 35 entries
36 .into_iter() 36 .into_iter()
37 .filter(|entry| entry.policy.allows_anonymous_ui())
37 .map(|entry| { 38 .map(|entry| {
38 let repo = crate::repos::open(&entry).ok(); 39 let repo = crate::repos::open(&entry).ok();
39 let description = resolve_description(&entry, repo.as_ref()); 40 let description = resolve_description(&entry, repo.as_ref());
@@ -95,11 +96,15 @@ fn read_local_description(entry: &crate::repos::RepoEntry) -> Option<String> {
95 } 96 }
96 97
97 fn resolve_description(entry: &crate::repos::RepoEntry, repo: Option<&git2::Repository>) -> String { 98 fn resolve_description(entry: &crate::repos::RepoEntry, repo: Option<&git2::Repository>) -> String {
98 repo.and_then(read_tracked_description) 99 entry
100 .policy
101 .normalized_description()
102 .or_else(|| repo.and_then(read_tracked_description))
99 .or_else(|| read_local_description(entry)) 103 .or_else(|| read_local_description(entry))
100 .unwrap_or_default() 104 .unwrap_or_default()
101 } 105 }
102 106
107 #[cfg(test)]
103 fn read_description(entry: &crate::repos::RepoEntry) -> String { 108 fn read_description(entry: &crate::repos::RepoEntry) -> String {
104 let repo = crate::repos::open(entry).ok(); 109 let repo = crate::repos::open(entry).ok();
105 resolve_description(entry, repo.as_ref()) 110 resolve_description(entry, repo.as_ref())
@@ -148,6 +153,7 @@ mod tests {
148 name: "repo".to_string(), 153 name: "repo".to_string(),
149 path: path.to_path_buf(), 154 path: path.to_path_buf(),
150 bare: false, 155 bare: false,
156 policy: crate::repos::RepoPolicy::default(),
151 } 157 }
152 } 158 }
153 159
@@ -156,10 +162,25 @@ mod tests {
156 name: "repo".to_string(), 162 name: "repo".to_string(),
157 path: path.to_path_buf(), 163 path: path.to_path_buf(),
158 bare: true, 164 bare: true,
165 policy: crate::repos::RepoPolicy::default(),
159 } 166 }
160 } 167 }
161 168
162 #[test] 169 #[test]
170 fn read_description_prefers_configured_policy_description() {
171 let tmp = TempDir::new().unwrap();
172 let bare_repo = tmp.path().join("repo.git");
173
174 git(tmp.path(), &["init", "--bare", "repo.git"]);
175 std::fs::write(bare_repo.join("description"), "Local bare description\n").unwrap();
176
177 let mut entry = make_bare_entry(&bare_repo);
178 entry.policy.description = Some("Configured description".to_string());
179
180 assert_eq!(read_description(&entry), "Configured description");
181 }
182
183 #[test]
163 fn read_description_prefers_tracked_description_for_bare_repo() { 184 fn read_description_prefers_tracked_description_for_bare_repo() {
164 let tmp = TempDir::new().unwrap(); 185 let tmp = TempDir::new().unwrap();
165 let bare_repo = tmp.path().join("repo.git"); 186 let bare_repo = tmp.path().join("repo.git");
src/server/repos.rs
Old New
@@ -1,11 +1,221 @@
1 use std::path::{Path, PathBuf}; 1 use std::path::{Path, PathBuf};
2 2
3 use serde::Deserialize;
4
3 /// A discovered git repository on disk. 5 /// A discovered git repository on disk.
4 #[derive(Debug, Clone)] 6 #[derive(Debug, Clone)]
5 pub struct RepoEntry { 7 pub struct RepoEntry {
6 pub name: String, 8 pub name: String,
7 pub path: PathBuf, 9 pub path: PathBuf,
8 pub bare: bool, 10 pub bare: bool,
11 pub policy: RepoPolicy,
12 }
13
14 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
15 #[serde(rename_all = "lowercase")]
16 pub enum RepoVisibility {
17 Public,
18 Private,
19 }
20
21 impl Default for RepoVisibility {
22 fn default() -> Self {
23 Self::Public
24 }
25 }
26
27 #[derive(Debug, Clone, Deserialize)]
28 pub struct RepoUiPolicy {
29 #[serde(default = "default_true")]
30 pub anonymous: bool,
31 }
32
33 impl Default for RepoUiPolicy {
34 fn default() -> Self {
35 Self { anonymous: true }
36 }
37 }
38
39 #[derive(Debug, Clone, Deserialize)]
40 pub struct RepoHttpPolicy {
41 #[serde(default = "default_true")]
42 pub anonymous_clone: bool,
43 }
44
45 impl Default for RepoHttpPolicy {
46 fn default() -> Self {
47 Self {
48 anonymous_clone: true,
49 }
50 }
51 }
52
53 #[derive(Debug, Clone, Deserialize)]
54 pub struct RepoAccessPolicy {
55 #[serde(default = "default_access_all")]
56 pub read: Vec<String>,
57 #[serde(default = "default_access_all")]
58 pub write: Vec<String>,
59 }
60
61 impl Default for RepoAccessPolicy {
62 fn default() -> Self {
63 Self {
64 read: default_access_all(),
65 write: default_access_all(),
66 }
67 }
68 }
69
70 #[derive(Debug, Clone, Deserialize, Default)]
71 pub struct RepoPolicy {
72 #[serde(default = "default_visibility")]
73 pub visibility: RepoVisibility,
74 #[serde(default)]
75 pub description: Option<String>,
76 #[serde(default)]
77 pub ui: RepoUiPolicy,
78 #[serde(default)]
79 pub http: RepoHttpPolicy,
80 #[serde(default)]
81 pub access: RepoAccessPolicy,
82 }
83
84 impl RepoPolicy {
85 fn hidden_due_to_error() -> Self {
86 Self {
87 visibility: RepoVisibility::Private,
88 description: None,
89 ui: RepoUiPolicy { anonymous: false },
90 http: RepoHttpPolicy {
91 anonymous_clone: false,
92 },
93 access: RepoAccessPolicy {
94 read: Vec::new(),
95 write: Vec::new(),
96 },
97 }
98 }
99
100 pub fn allows_anonymous_ui(&self) -> bool {
101 self.visibility == RepoVisibility::Public && self.ui.anonymous
102 }
103
104 pub fn allows_anonymous_http(&self) -> bool {
105 self.visibility == RepoVisibility::Public && self.http.anonymous_clone
106 }
107
108 pub fn allows_read(&self, principal: &str) -> bool {
109 access_allows(&self.access.read, principal)
110 }
111
112 pub fn allows_write(&self, principal: &str) -> bool {
113 access_allows(&self.access.write, principal)
114 }
115
116 pub fn normalized_description(&self) -> Option<String> {
117 let description = self.description.as_deref()?.trim();
118 if description.is_empty() {
119 None
120 } else {
121 Some(description.to_string())
122 }
123 }
124 }
125
126 fn default_true() -> bool {
127 true
128 }
129
130 fn default_visibility() -> RepoVisibility {
131 RepoVisibility::Public
132 }
133
134 fn default_access_all() -> Vec<String> {
135 vec!["*".to_string()]
136 }
137
138 fn access_allows(entries: &[String], principal: &str) -> bool {
139 entries
140 .iter()
141 .any(|entry| entry == "*" || entry == principal)
142 }
143
144 /// Return the repo-local server policy path.
145 pub fn repo_server_config_path(entry_path: &Path, bare: bool) -> PathBuf {
146 if bare {
147 entry_path.join(".collab").join("server.toml")
148 } else {
149 entry_path.join(".git").join(".collab").join("server.toml")
150 }
151 }
152
153 fn repo_name(dir_name: &str) -> String {
154 dir_name
155 .strip_suffix(".git")
156 .unwrap_or(dir_name)
157 .to_string()
158 }
159
160 fn load_policy(path: &Path, bare: bool) -> RepoPolicy {
161 let config_path = repo_server_config_path(path, bare);
162 if !config_path.exists() {
163 return RepoPolicy::default();
164 }
165
166 let content = match std::fs::read_to_string(&config_path) {
167 Ok(content) => content,
168 Err(error) => {
169 tracing::warn!(
170 "failed to read repo policy from {:?}: {}; hiding repo",
171 config_path,
172 error
173 );
174 return RepoPolicy::hidden_due_to_error();
175 }
176 };
177
178 match toml_edit::de::from_str::<RepoPolicy>(&content) {
179 Ok(policy) => policy,
180 Err(error) => {
181 tracing::warn!(
182 "failed to parse repo policy from {:?}: {}; hiding repo",
183 config_path,
184 error
185 );
186 RepoPolicy::hidden_due_to_error()
187 }
188 }
189 }
190
191 fn repo_entry_from_path(path: &Path, dir_name: &str) -> Option<RepoEntry> {
192 if path.join("HEAD").is_file() {
193 Some(RepoEntry {
194 name: repo_name(dir_name),
195 path: path.to_path_buf(),
196 bare: true,
197 policy: load_policy(path, true),
198 })
199 } else if path.join(".git").is_dir() {
200 Some(RepoEntry {
201 name: dir_name.to_string(),
202 path: path.to_path_buf(),
203 bare: false,
204 policy: load_policy(path, false),
205 })
206 } else {
207 None
208 }
209 }
210
211 /// Build a repo entry directly from an on-disk path.
212 pub fn entry_for_path(path: &Path) -> Option<RepoEntry> {
213 if !path.is_dir() {
214 return None;
215 }
216
217 let dir_name = path.file_name()?.to_string_lossy().to_string();
218 repo_entry_from_path(path, &dir_name)
9 } 219 }
10 220
11 /// Scan a directory for git repositories. 221 /// Scan a directory for git repositories.
@@ -21,23 +231,8 @@ pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> {
21 } 231 }
22 232
23 let dir_name = entry.file_name().to_string_lossy().to_string(); 233 let dir_name = entry.file_name().to_string_lossy().to_string();
24 234 if let Some(repo) = repo_entry_from_path(&path, &dir_name) {
25 if path.join("HEAD").is_file() { 235 entries.push(repo);
26 let name = dir_name
27 .strip_suffix(".git")
28 .unwrap_or(&dir_name)
29 .to_string();
30 entries.push(RepoEntry {
31 name,
32 path,
33 bare: true,
34 });
35 } else if path.join(".git").is_dir() {
36 entries.push(RepoEntry {
37 name: dir_name,
38 path,
39 bare: false,
40 });
41 } 236 }
42 } 237 }
43 238
@@ -84,6 +279,12 @@ mod tests {
84 .expect("git init failed"); 279 .expect("git init failed");
85 } 280 }
86 281
282 fn write_policy(repo_path: &Path, bare: bool, content: &str) {
283 let policy_path = repo_server_config_path(repo_path, bare);
284 std::fs::create_dir_all(policy_path.parent().unwrap()).unwrap();
285 std::fs::write(policy_path, content).unwrap();
286 }
287
87 #[test] 288 #[test]
88 fn discover_bare_repos() { 289 fn discover_bare_repos() {
89 let tmp = TempDir::new().unwrap(); 290 let tmp = TempDir::new().unwrap();
@@ -138,4 +339,96 @@ mod tests {
138 let repo = open(&entry).unwrap(); 339 let repo = open(&entry).unwrap();
139 assert!(repo.is_bare()); 340 assert!(repo.is_bare());
140 } 341 }
342
343 #[test]
344 fn config_path_uses_git_dir_for_non_bare_repos() {
345 let repo_path = Path::new("/srv/git/project");
346 assert_eq!(
347 repo_server_config_path(repo_path, false),
348 PathBuf::from("/srv/git/project/.git/.collab/server.toml")
349 );
350 }
351
352 #[test]
353 fn config_path_uses_repo_root_for_bare_repos() {
354 let repo_path = Path::new("/srv/git/project.git");
355 assert_eq!(
356 repo_server_config_path(repo_path, true),
357 PathBuf::from("/srv/git/project.git/.collab/server.toml")
358 );
359 }
360
361 #[test]
362 fn missing_policy_defaults_to_public_access() {
363 let tmp = TempDir::new().unwrap();
364 init_bare(tmp.path(), "public.git");
365 let entry = entry_for_path(&tmp.path().join("public.git")).unwrap();
366 assert_eq!(entry.policy.visibility, RepoVisibility::Public);
367 assert!(entry.policy.allows_anonymous_ui());
368 assert!(entry.policy.allows_anonymous_http());
369 assert!(entry.policy.allows_read("key:SHA256:any"));
370 assert!(entry.policy.allows_write("key:SHA256:any"));
371 }
372
373 #[test]
374 fn bare_repo_policy_is_loaded_from_collab_directory() {
375 let tmp = TempDir::new().unwrap();
376 let repo_path = tmp.path().join("secret.git");
377 init_bare(tmp.path(), "secret.git");
378 write_policy(
379 &repo_path,
380 true,
381 "visibility = \"private\"\ndescription = \"Secret repo\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n[access]\nread = [\"key:SHA256:reader\"]\nwrite = [\"key:SHA256:writer\"]\n",
382 );
383
384 let entry = entry_for_path(&repo_path).unwrap();
385 assert_eq!(entry.policy.visibility, RepoVisibility::Private);
386 assert_eq!(
387 entry.policy.normalized_description().as_deref(),
388 Some("Secret repo")
389 );
390 assert!(!entry.policy.allows_anonymous_ui());
391 assert!(!entry.policy.allows_anonymous_http());
392 assert!(entry.policy.allows_read("key:SHA256:reader"));
393 assert!(!entry.policy.allows_read("key:SHA256:other"));
394 assert!(entry.policy.allows_write("key:SHA256:writer"));
395 assert!(!entry.policy.allows_write("key:SHA256:reader"));
396 }
397
398 #[test]
399 fn non_bare_repo_policy_is_loaded_from_git_dir() {
400 let tmp = TempDir::new().unwrap();
401 let repo_path = tmp.path().join("workspace");
402 init_non_bare(tmp.path(), "workspace");
403 write_policy(
404 &repo_path,
405 false,
406 "description = \"Workspace repo\"\n[access]\nwrite = [\"key:SHA256:writer\"]\n",
407 );
408
409 let entry = entry_for_path(&repo_path).unwrap();
410 assert_eq!(
411 entry.policy.normalized_description().as_deref(),
412 Some("Workspace repo")
413 );
414 assert!(entry.policy.allows_anonymous_ui());
415 assert!(entry.policy.allows_read("key:SHA256:any"));
416 assert!(entry.policy.allows_write("key:SHA256:writer"));
417 assert!(!entry.policy.allows_write("key:SHA256:other"));
418 }
419
420 #[test]
421 fn malformed_policy_hides_repo() {
422 let tmp = TempDir::new().unwrap();
423 let repo_path = tmp.path().join("broken.git");
424 init_bare(tmp.path(), "broken.git");
425 write_policy(&repo_path, true, "visibility = [broken toml");
426
427 let entry = entry_for_path(&repo_path).unwrap();
428 assert_eq!(entry.policy.visibility, RepoVisibility::Private);
429 assert!(!entry.policy.allows_anonymous_ui());
430 assert!(!entry.policy.allows_anonymous_http());
431 assert!(!entry.policy.allows_read("key:SHA256:any"));
432 assert!(!entry.policy.allows_write("key:SHA256:any"));
433 }
141 } 434 }
src/server/ssh/session.rs
Old New
@@ -22,7 +22,7 @@ pub struct SshServerConfig {
22 /// Per-connection SSH session handler. 22 /// Per-connection SSH session handler.
23 pub struct SshHandler { 23 pub struct SshHandler {
24 config: Arc<SshServerConfig>, 24 config: Arc<SshServerConfig>,
25 authenticated: bool, 25 authenticated_principal: Option<String>,
26 /// Sender for forwarding client data (stdin) to the spawned git subprocess. 26 /// Sender for forwarding client data (stdin) to the spawned git subprocess.
27 stdin_tx: Option<mpsc::Sender<Vec<u8>>>, 27 stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
28 } 28 }
@@ -31,12 +31,16 @@ impl SshHandler {
31 pub fn new(config: Arc<SshServerConfig>) -> Self { 31 pub fn new(config: Arc<SshServerConfig>) -> Self {
32 Self { 32 Self {
33 config, 33 config,
34 authenticated: false, 34 authenticated_principal: None,
35 stdin_tx: None, 35 stdin_tx: None,
36 } 36 }
37 } 37 }
38 } 38 }
39 39
40 fn ssh_key_principal(public_key: &PublicKey) -> String {
41 format!("key:SHA256:{}", public_key.fingerprint())
42 }
43
40 /// Parse a git command string like `git-upload-pack '/path/to/repo.git'`. 44 /// Parse a git command string like `git-upload-pack '/path/to/repo.git'`.
41 /// Returns (command, repo_path) or None if the command is not allowed. 45 /// Returns (command, repo_path) or None if the command is not allowed.
42 pub fn parse_git_command(data: &str) -> Option<(&str, &str)> { 46 pub fn parse_git_command(data: &str) -> Option<(&str, &str)> {
@@ -133,10 +137,14 @@ impl Handler for SshHandler {
133 137
134 let key_type = public_key.name(); 138 let key_type = public_key.name();
135 let key_data = public_key.public_key_base64(); 139 let key_data = public_key.public_key_base64();
140 let principal = ssh_key_principal(public_key);
136 141
137 if is_authorized(&keys, key_type, &key_data) { 142 if is_authorized(&keys, key_type, &key_data) {
138 info!("Public key auth accepted for key type {}", key_type); 143 info!(
139 self.authenticated = true; 144 "Public key auth accepted for key type {} ({})",
145 key_type, principal
146 );
147 self.authenticated_principal = Some(principal);
140 Ok(Auth::Accept) 148 Ok(Auth::Accept)
141 } else { 149 } else {
142 debug!("Public key auth rejected for key type {}", key_type); 150 debug!("Public key auth rejected for key type {}", key_type);
@@ -151,7 +159,7 @@ impl Handler for SshHandler {
151 channel: Channel<Msg>, 159 channel: Channel<Msg>,
152 _session: &mut Session, 160 _session: &mut Session,
153 ) -> Result<bool, Self::Error> { 161 ) -> Result<bool, Self::Error> {
154 if self.authenticated { 162 if self.authenticated_principal.is_some() {
155 debug!("Session channel opened: {:?}", channel.id()); 163 debug!("Session channel opened: {:?}", channel.id());
156 Ok(true) 164 Ok(true)
157 } else { 165 } else {
@@ -189,6 +197,17 @@ impl Handler for SshHandler {
189 } 197 }
190 }; 198 };
191 199
200 let principal = match self.authenticated_principal.clone() {
201 Some(principal) => principal,
202 None => {
203 warn!("Rejected exec request: not authenticated");
204 session.exit_status_request(channel, 1);
205 session.eof(channel);
206 session.close(channel);
207 return Ok(());
208 }
209 };
210
192 let resolved_path = match resolve_repo_path(&self.config.repos_dir, &repo_path) { 211 let resolved_path = match resolve_repo_path(&self.config.repos_dir, &repo_path) {
193 Some(p) => p, 212 Some(p) => p,
194 None => { 213 None => {
@@ -200,7 +219,38 @@ impl Handler for SshHandler {
200 } 219 }
201 }; 220 };
202 221
203 if !resolved_path.exists() { 222 if resolved_path.exists() {
223 let entry = match crate::repos::entry_for_path(&resolved_path) {
224 Some(entry) => entry,
225 None => {
226 warn!(
227 "Rejected exec request: path is not a git repo: {:?}",
228 resolved_path
229 );
230 session.exit_status_request(channel, 1);
231 session.eof(channel);
232 session.close(channel);
233 return Ok(());
234 }
235 };
236
237 let authorized = match git_cmd.as_str() {
238 "git-upload-pack" => entry.policy.allows_read(&principal),
239 "git-receive-pack" => entry.policy.allows_write(&principal),
240 _ => false,
241 };
242
243 if !authorized {
244 warn!(
245 "Rejected exec request: principal {} is not authorized for {} on {:?}",
246 principal, git_cmd, resolved_path
247 );
248 session.exit_status_request(channel, 1);
249 session.eof(channel);
250 session.close(channel);
251 return Ok(());
252 }
253 } else {
204 match ensure_repo_exists_for_command(&git_cmd, &resolved_path) { 254 match ensure_repo_exists_for_command(&git_cmd, &resolved_path) {
205 Ok(true) => { 255 Ok(true) => {
206 info!("Created bare repo for receive-pack: {:?}", resolved_path); 256 info!("Created bare repo for receive-pack: {:?}", resolved_path);
tests/collab_test.rs
Old New
@@ -10,8 +10,7 @@ use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
10 10
11 use common::{ 11 use common::{
12 add_comment, add_review, add_review_on, alice, bob, close_issue, create_patch, init_repo, now, 12 add_comment, add_review, add_review_on, alice, bob, close_issue, create_patch, init_repo, now,
13 open_issue, 13 open_issue, reopen_issue, setup_signing_key, test_signing_key,
14 reopen_issue, setup_signing_key, test_signing_key,
15 }; 14 };
16 15
17 // --------------------------------------------------------------------------- 16 // ---------------------------------------------------------------------------
tests/common/mod.rs
Old New
@@ -573,6 +573,18 @@ impl ServerHarness {
573 .git(&["push", "origin", "refs/collab/*:refs/collab/*"]); 573 .git(&["push", "origin", "refs/collab/*:refs/collab/*"]);
574 } 574 }
575 575
576 pub fn write_repo_server_policy(&self, content: &str) {
577 let policy_path = self
578 .root
579 .path()
580 .join("repos")
581 .join(format!("{}.git", self.repo_name))
582 .join(".collab")
583 .join("server.toml");
584 std::fs::create_dir_all(policy_path.parent().unwrap()).unwrap();
585 std::fs::write(policy_path, content).unwrap();
586 }
587
576 pub fn get_ok(&self, path: &str) -> HttpResponse { 588 pub fn get_ok(&self, path: &str) -> HttpResponse {
577 let response = self.get(path); 589 let response = self.get(path);
578 assert!( 590 assert!(
tests/review_test.rs
Old New
@@ -40,7 +40,13 @@ fn setup_patch_dag(repo: &git2::Repository) -> &'static str {
40 local_ref 40 local_ref
41 } 41 }
42 42
43 fn review_event(author: git_collab::event::Author, verdict: ReviewVerdict, body: &str, revision: u32, ts: &str) -> Event { 43 fn review_event(
44 author: git_collab::event::Author,
45 verdict: ReviewVerdict,
46 body: &str,
47 revision: u32,
48 ts: &str,
49 ) -> Event {
44 Event { 50 Event {
45 timestamp: ts.to_string(), 51 timestamp: ts.to_string(),
46 author, 52 author,
@@ -60,13 +66,29 @@ fn duplicate_vote_same_author_same_revision_collapses_to_latest() {
60 let sk = test_signing_key(); 66 let sk = test_signing_key();
61 let ref_name = setup_patch_dag(&repo); 67 let ref_name = setup_patch_dag(&repo);
62 68
63 let e1 = review_event(bob(), ReviewVerdict::Approve, "lgtm", 1, "2026-01-02T00:00:00Z"); 69 let e1 = review_event(
64 let e2 = review_event(bob(), ReviewVerdict::Approve, "lgtm again", 1, "2026-01-03T00:00:00Z"); 70 bob(),
71 ReviewVerdict::Approve,
72 "lgtm",
73 1,
74 "2026-01-02T00:00:00Z",
75 );
76 let e2 = review_event(
77 bob(),
78 ReviewVerdict::Approve,
79 "lgtm again",
80 1,
81 "2026-01-03T00:00:00Z",
82 );
65 dag::append_event(&repo, ref_name, &e1, &sk).unwrap(); 83 dag::append_event(&repo, ref_name, &e1, &sk).unwrap();
66 dag::append_event(&repo, ref_name, &e2, &sk).unwrap(); 84 dag::append_event(&repo, ref_name, &e2, &sk).unwrap();
67 85
68 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap(); 86 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap();
69 assert_eq!(state.reviews.len(), 1, "duplicate approvals should collapse"); 87 assert_eq!(
88 state.reviews.len(),
89 1,
90 "duplicate approvals should collapse"
91 );
70 assert_eq!(state.reviews[0].body, "lgtm again", "latest review wins"); 92 assert_eq!(state.reviews[0].body, "lgtm again", "latest review wins");
71 assert_eq!(state.reviews[0].verdict, ReviewVerdict::Approve); 93 assert_eq!(state.reviews[0].verdict, ReviewVerdict::Approve);
72 } 94 }
@@ -78,7 +100,13 @@ fn changed_vote_same_author_same_revision_supersedes() {
78 let sk = test_signing_key(); 100 let sk = test_signing_key();
79 let ref_name = setup_patch_dag(&repo); 101 let ref_name = setup_patch_dag(&repo);
80 102
81 let e1 = review_event(bob(), ReviewVerdict::Approve, "lgtm", 1, "2026-01-02T00:00:00Z"); 103 let e1 = review_event(
104 bob(),
105 ReviewVerdict::Approve,
106 "lgtm",
107 1,
108 "2026-01-02T00:00:00Z",
109 );
82 let e2 = review_event( 110 let e2 = review_event(
83 bob(), 111 bob(),
84 ReviewVerdict::RequestChanges, 112 ReviewVerdict::RequestChanges,
@@ -102,13 +130,29 @@ fn votes_on_different_revisions_are_kept() {
102 let sk = test_signing_key(); 130 let sk = test_signing_key();
103 let ref_name = setup_patch_dag(&repo); 131 let ref_name = setup_patch_dag(&repo);
104 132
105 let e1 = review_event(bob(), ReviewVerdict::Approve, "lgtm rev 1", 1, "2026-01-02T00:00:00Z"); 133 let e1 = review_event(
106 let e2 = review_event(bob(), ReviewVerdict::Approve, "lgtm rev 2", 2, "2026-01-03T00:00:00Z"); 134 bob(),
135 ReviewVerdict::Approve,
136 "lgtm rev 1",
137 1,
138 "2026-01-02T00:00:00Z",
139 );
140 let e2 = review_event(
141 bob(),
142 ReviewVerdict::Approve,
143 "lgtm rev 2",
144 2,
145 "2026-01-03T00:00:00Z",
146 );
107 dag::append_event(&repo, ref_name, &e1, &sk).unwrap(); 147 dag::append_event(&repo, ref_name, &e1, &sk).unwrap();
108 dag::append_event(&repo, ref_name, &e2, &sk).unwrap(); 148 dag::append_event(&repo, ref_name, &e2, &sk).unwrap();
109 149
110 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap(); 150 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap();
111 assert_eq!(state.reviews.len(), 2, "votes on different revisions both count"); 151 assert_eq!(
152 state.reviews.len(),
153 2,
154 "votes on different revisions both count"
155 );
112 } 156 }
113 157
114 #[test] 158 #[test]
@@ -118,13 +162,29 @@ fn votes_from_different_authors_are_kept() {
118 let sk = test_signing_key(); 162 let sk = test_signing_key();
119 let ref_name = setup_patch_dag(&repo); 163 let ref_name = setup_patch_dag(&repo);
120 164
121 let e1 = review_event(alice(), ReviewVerdict::Approve, "lgtm", 1, "2026-01-02T00:00:00Z"); 165 let e1 = review_event(
122 let e2 = review_event(bob(), ReviewVerdict::Approve, "lgtm too", 1, "2026-01-03T00:00:00Z"); 166 alice(),
167 ReviewVerdict::Approve,
168 "lgtm",
169 1,
170 "2026-01-02T00:00:00Z",
171 );
172 let e2 = review_event(
173 bob(),
174 ReviewVerdict::Approve,
175 "lgtm too",
176 1,
177 "2026-01-03T00:00:00Z",
178 );
123 dag::append_event(&repo, ref_name, &e1, &sk).unwrap(); 179 dag::append_event(&repo, ref_name, &e1, &sk).unwrap();
124 dag::append_event(&repo, ref_name, &e2, &sk).unwrap(); 180 dag::append_event(&repo, ref_name, &e2, &sk).unwrap();
125 181
126 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap(); 182 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap();
127 assert_eq!(state.reviews.len(), 2, "different authors' votes both count"); 183 assert_eq!(
184 state.reviews.len(),
185 2,
186 "different authors' votes both count"
187 );
128 } 188 }
129 189
130 #[test] 190 #[test]
@@ -134,13 +194,29 @@ fn comment_verdict_reviews_always_append() {
134 let sk = test_signing_key(); 194 let sk = test_signing_key();
135 let ref_name = setup_patch_dag(&repo); 195 let ref_name = setup_patch_dag(&repo);
136 196
137 let e1 = review_event(bob(), ReviewVerdict::Comment, "first thought", 1, "2026-01-02T00:00:00Z"); 197 let e1 = review_event(
138 let e2 = review_event(bob(), ReviewVerdict::Comment, "second thought", 1, "2026-01-03T00:00:00Z"); 198 bob(),
199 ReviewVerdict::Comment,
200 "first thought",
201 1,
202 "2026-01-02T00:00:00Z",
203 );
204 let e2 = review_event(
205 bob(),
206 ReviewVerdict::Comment,
207 "second thought",
208 1,
209 "2026-01-03T00:00:00Z",
210 );
139 dag::append_event(&repo, ref_name, &e1, &sk).unwrap(); 211 dag::append_event(&repo, ref_name, &e1, &sk).unwrap();
140 dag::append_event(&repo, ref_name, &e2, &sk).unwrap(); 212 dag::append_event(&repo, ref_name, &e2, &sk).unwrap();
141 213
142 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap(); 214 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap();
143 assert_eq!(state.reviews.len(), 2, "comment-verdict reviews are not votes; keep all"); 215 assert_eq!(
216 state.reviews.len(),
217 2,
218 "comment-verdict reviews are not votes; keep all"
219 );
144 } 220 }
145 221
146 #[test] 222 #[test]
@@ -150,13 +226,29 @@ fn vote_does_not_supersede_comment_verdict_review() {
150 let sk = test_signing_key(); 226 let sk = test_signing_key();
151 let ref_name = setup_patch_dag(&repo); 227 let ref_name = setup_patch_dag(&repo);
152 228
153 let e1 = review_event(bob(), ReviewVerdict::Comment, "just a note", 1, "2026-01-02T00:00:00Z"); 229 let e1 = review_event(
154 let e2 = review_event(bob(), ReviewVerdict::Approve, "lgtm", 1, "2026-01-03T00:00:00Z"); 230 bob(),
231 ReviewVerdict::Comment,
232 "just a note",
233 1,
234 "2026-01-02T00:00:00Z",
235 );
236 let e2 = review_event(
237 bob(),
238 ReviewVerdict::Approve,
239 "lgtm",
240 1,
241 "2026-01-03T00:00:00Z",
242 );
155 dag::append_event(&repo, ref_name, &e1, &sk).unwrap(); 243 dag::append_event(&repo, ref_name, &e1, &sk).unwrap();
156 dag::append_event(&repo, ref_name, &e2, &sk).unwrap(); 244 dag::append_event(&repo, ref_name, &e2, &sk).unwrap();
157 245
158 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap(); 246 let state = PatchState::from_ref(&repo, ref_name, "test-patch").unwrap();
159 assert_eq!(state.reviews.len(), 2, "vote should not replace a comment-verdict review"); 247 assert_eq!(
248 state.reviews.len(),
249 2,
250 "vote should not replace a comment-verdict review"
251 );
160 } 252 }
161 253
162 // =========================================================================== 254 // ===========================================================================
@@ -229,5 +321,9 @@ fn cli_re_approving_new_revision_is_allowed() {
229 let out = repo.run_ok(&["patch", "show", &id, "--json"]); 321 let out = repo.run_ok(&["patch", "show", &id, "--json"]);
230 let json: serde_json::Value = serde_json::from_str(&out).unwrap(); 322 let json: serde_json::Value = serde_json::from_str(&out).unwrap();
231 let reviews = json["reviews"].as_array().unwrap(); 323 let reviews = json["reviews"].as_array().unwrap();
232 assert_eq!(reviews.len(), 2, "approvals on different revisions both count"); 324 assert_eq!(
325 reviews.len(),
326 2,
327 "approvals on different revisions both count"
328 );
233 } 329 }
tests/server_behavior_test.rs
Old New
@@ -80,6 +80,56 @@ fn pushed_repository_content_renders_across_repo_http_pages() {
80 } 80 }
81 81
82 #[test] 82 #[test]
83 fn private_repo_policy_hides_repo_from_ui_and_uses_configured_description() {
84 let harness = ServerHarness::new("behavior-private");
85
86 harness.push_head();
87 harness.write_repo_server_policy(
88 "visibility = \"private\"\ndescription = \"Hidden repo\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n[access]\nread = [\"*\"]\nwrite = [\"*\"]\n",
89 );
90
91 let repo_list = harness.get_ok("/");
92 assert!(!repo_list.body.contains(harness.repo_name()));
93 assert!(!repo_list.body.contains("Hidden repo"));
94
95 let overview = harness.get(&format!("/{}", harness.repo_name()));
96 assert!(overview.status_line.contains("404"));
97 assert!(overview.body.contains(harness.repo_name()));
98 assert!(overview.body.contains("not found"));
99 }
100
101 #[test]
102 fn private_repo_policy_blocks_anonymous_smart_http() {
103 let harness = ServerHarness::new("behavior-private-http");
104
105 harness.push_head();
106 harness.write_repo_server_policy(
107 "visibility = \"private\"\ndescription = \"Hidden repo\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n[access]\nread = [\"*\"]\nwrite = [\"*\"]\n",
108 );
109
110 let info_refs = harness.get(&format!(
111 "/{}.git/info/refs?service=git-upload-pack",
112 harness.repo_name()
113 ));
114 assert!(info_refs.status_line.contains("404"));
115 assert!(info_refs.body.contains(harness.repo_name()));
116 }
117
118 #[test]
119 fn public_repo_policy_description_appears_on_repo_list() {
120 let harness = ServerHarness::new("behavior-description");
121
122 harness.push_head();
123 harness.write_repo_server_policy(
124 "visibility = \"public\"\ndescription = \"Configured public description\"\n[ui]\nanonymous = true\n[http]\nanonymous_clone = true\n[access]\nread = [\"*\"]\nwrite = [\"*\"]\n",
125 );
126
127 let repo_list = harness.get_ok("/");
128 assert!(repo_list.body.contains(harness.repo_name()));
129 assert!(repo_list.body.contains("Configured public description"));
130 }
131
132 #[test]
83 fn missing_repository_and_missing_objects_return_not_found() { 133 fn missing_repository_and_missing_objects_return_not_found() {
84 let harness = ServerHarness::new("behavior-not-found"); 134 let harness = ServerHarness::new("behavior-not-found");
85 135