docs/superpowers/plans/2026-08-08-release-packages.md
Ref: Size: 79.9 KiB History
# Release Packages Implementation Plan
> **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.
**Goal:** Upload release artifacts (tar.gz etc.) to the git-collab server over SSH and distribute them via public HTTP URLs.
**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`).
**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.
**Spec:** `docs/superpowers/specs/2026-08-08-release-packages-design.md`
**Codebase facts the implementer needs:**
- 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::…`.
- 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`.
- The SSH handler currently has NO `channel_eof` implementation. Upload-until-EOF requires one.
- 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`).
- Every template extending `repo_base.html` must provide fields: `site_title`, `repo_name`, `active_section`, `open_patches`, `open_issues`.
- 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.
- Run tests with `cargo test`, lints with `cargo clippy --all-targets`.
- 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.
---
### Task 1: Shared name validation in the lib
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.
**Files:**
- Create: `src/release.rs`
- Modify: `src/lib.rs` (add `pub mod release;`)
- [ ] **Step 1: Write the failing tests**
Create `src/release.rs`:
```rust
//! Client-side release commands and shared release name validation.
/// Maximum length in bytes for a release version or filename.
pub const MAX_NAME_LEN: usize = 128;
/// Validate a release version or filename: ASCII alphanumeric first char,
/// then alphanumeric plus `.`, `_`, `-`. No slashes, no leading dot, max 128 bytes.
pub fn validate_name(name: &str) -> bool {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_typical_names() {
assert!(validate_name("v1.2.0"));
assert!(validate_name("app-x86_64.tar.gz"));
assert!(validate_name("1.0"));
assert!(validate_name("RELEASE_2026"));
}
#[test]
fn rejects_traversal_and_separators() {
assert!(!validate_name("../etc"));
assert!(!validate_name("a/b"));
assert!(!validate_name("a\\b"));
assert!(!validate_name(".hidden"));
assert!(!validate_name(".."));
}
#[test]
fn rejects_empty_weird_and_overlong() {
assert!(!validate_name(""));
assert!(!validate_name("näme"));
assert!(!validate_name("a b"));
assert!(!validate_name("-leading-dash"));
assert!(!validate_name(&"a".repeat(129)));
assert!(validate_name(&"a".repeat(128)));
}
}
```
In `src/lib.rs`, add `pub mod release;` to the module list (alphabetical, after `pub mod patch;`).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --lib release::`
Expected: FAIL (panic at `todo!()`)
- [ ] **Step 3: Implement**
Replace the `todo!()` body:
```rust
pub fn validate_name(name: &str) -> bool {
if name.is_empty() || name.len() > MAX_NAME_LEN {
return false;
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphanumeric() {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --lib release::`
Expected: PASS (3 tests)
- [ ] **Step 5: Commit**
```bash
git add src/release.rs src/lib.rs
git commit -m "Add shared release name validation"
```
---
### Task 2: Server release store
**Execution deviations:**
- `list_releases`: fixed a type-inference conflict — the `NotFound` early return forced `versions: Vec<ReleaseVersion>` while the loop later pushed `(SystemTime, ReleaseVersion)` tuples; gave `versions` an explicit tuple type and returned `Vec::new()` directly from the early-return branch.
- `finish()`: dropped the no-op `self.temp.flush()` and call `self.temp.as_file().sync_all()?` before persisting, for durability across crashes.
- `finish()`: in the force branch only, delete the existing `.sha256` companion before persisting (ignoring `NotFound`), then persist, then write the new companion, so a crash window degrades to "companion missing" rather than "companion wrong"; the no-force branch does not pre-delete, so a rejected duplicate upload leaves the existing artifact's valid companion untouched.
- `finish()`: map `persist_noclobber`'s `AlreadyExists` io error to `ReleaseError::AlreadyExists` (was surfacing a raw "os error 17"); `ReleaseUpload` now stores `version` to build the message.
- `finish()`: chmod the persisted artifact to `0o644` before persisting (unix-only), so it doesn't keep `NamedTempFile`'s `0600`.
- `list_releases`: skip versions whose `files` list is empty, since `begin()` creates the version dir before any bytes arrive and an aborted upload can otherwise phantom-list a zero-file version.
- `list_releases`: descending-mtime sort now breaks ties by version name descending, so equal-mtime versions still order lexically-newest-first.
- Added `pub fn artifact_path(releases_dir, version, filename) -> Result<PathBuf, ReleaseError>` (validates both segments, strips a single trailing `.sha256` before validating, requires `is_file()`) — **Task 7's HTTP download route must use this instead of hand-joining paths.**
- Added a concurrency note to the module doc comment: concurrent force-uploads to the same version/filename aren't serialized; single-writer deployments (current design) are unaffected.
Filesystem store: atomic uploads with SHA-256, listing, deletion. Lives in the server bin crate.
**Files:**
- Modify: `Cargo.toml` (add `sha2`, `tokio-util`; move `tempfile` to `[dependencies]`)
- Create: `src/server/releases.rs`
- Modify: `src/server/main.rs` (add `mod releases;`)
- [ ] **Step 1: Add dependencies**
In `Cargo.toml` `[dependencies]`, add:
```toml
sha2 = "0.10"
tempfile = "3"
tokio-util = { version = "0.7", features = ["io"] }
```
Remove `tempfile = "3"` from `[dev-dependencies]` (it is now a regular dependency and stays visible to tests).
- [ ] **Step 2: Write the module skeleton and failing tests**
Create `src/server/releases.rs`:
```rust
//! Filesystem-backed release artifact store.
//!
//! Layout: `<releases_dir>/<version>/<filename>` plus `<filename>.sha256`
//! (sha256sum-compatible: "<hex> <filename>\n"). No manifest — the
//! filesystem is the index.
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::Serialize;
use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;
use git_collab::release::validate_name;
pub const DEFAULT_MAX_RELEASE_SIZE: u64 = 1024 * 1024 * 1024; // 1 GiB
#[derive(Debug, thiserror::Error)]
pub enum ReleaseError {
#[error("invalid name: {0}")]
InvalidName(String),
#[error("{0} already exists (use --force to replace)")]
AlreadyExists(String),
#[error("file exceeds maximum release size ({0} bytes)")]
TooLarge(u64),
#[error("not found: {0}")]
NotFound(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
/// The releases directory for a repo entry: `<gitdir>/collab/releases`.
pub fn releases_dir(entry: &crate::repos::RepoEntry) -> PathBuf {
if entry.bare {
entry.path.join("collab").join("releases")
} else {
entry.path.join(".git").join("collab").join("releases")
}
}
/// An in-progress streamed upload. Bytes go to a temp file in the version
/// directory; `finish()` atomically renames into place and writes the
/// `.sha256` companion. Dropping without `finish()` cleans up the temp file.
pub struct ReleaseUpload {
temp: NamedTempFile,
hasher: Sha256,
written: u64,
max_size: u64,
dest: PathBuf,
sha_dest: PathBuf,
filename: String,
force: bool,
}
impl ReleaseUpload {
pub fn begin(
releases_dir: &Path,
version: &str,
filename: &str,
force: bool,
max_size: u64,
) -> Result<Self, ReleaseError> {
todo!()
}
pub fn write(&mut self, chunk: &[u8]) -> Result<(), ReleaseError> {
todo!()
}
/// Finalize: atomic rename + write `.sha256`. Returns the hex digest.
pub fn finish(self) -> Result<String, ReleaseError> {
todo!()
}
}
#[derive(Debug, Serialize)]
pub struct ReleaseFile {
pub name: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Serialize)]
pub struct ReleaseVersion {
pub version: String,
pub published: String,
pub files: Vec<ReleaseFile>,
}
#[derive(Debug, Serialize)]
pub struct ReleaseIndex {
pub versions: Vec<ReleaseVersion>,
}
/// List versions newest-first (by directory mtime), files alphabetically.
/// `.sha256` companions and dotfiles are not listed as files.
/// A missing releases dir is an empty index.
pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError> {
todo!()
}
/// Delete one file (and its `.sha256`) or, with `filename: None`, a whole
/// version. Removing the last file of a version removes the version dir.
pub fn delete_release(
releases_dir: &Path,
version: &str,
filename: Option<&str>,
) -> Result<(), ReleaseError> {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn upload(dir: &Path, version: &str, name: &str, content: &[u8], force: bool) -> Result<String, ReleaseError> {
let mut up = ReleaseUpload::begin(dir, version, name, force, DEFAULT_MAX_RELEASE_SIZE)?;
up.write(content)?;
up.finish()
}
#[test]
fn upload_writes_file_and_checksum() {
let tmp = TempDir::new().unwrap();
let sha = upload(tmp.path(), "v1.0.0", "app.tar.gz", b"hello", false).unwrap();
// sha256 of "hello"
assert_eq!(
sha,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
let file = tmp.path().join("v1.0.0").join("app.tar.gz");
assert_eq!(std::fs::read(&file).unwrap(), b"hello");
let sha_file = tmp.path().join("v1.0.0").join("app.tar.gz.sha256");
assert_eq!(
std::fs::read_to_string(&sha_file).unwrap(),
format!("{} app.tar.gz\n", sha)
);
}
#[test]
fn duplicate_upload_rejected_without_force() {
let tmp = TempDir::new().unwrap();
upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap();
let err = upload(tmp.path(), "v1", "a.tar.gz", b"two", false).unwrap_err();
assert!(matches!(err, ReleaseError::AlreadyExists(_)));
// original content untouched
assert_eq!(
std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(),
b"one"
);
}
#[test]
fn force_replaces_file_and_checksum() {
let tmp = TempDir::new().unwrap();
upload(tmp.path(), "v1", "a.tar.gz", b"one", false).unwrap();
let sha2 = upload(tmp.path(), "v1", "a.tar.gz", b"two", true).unwrap();
assert_eq!(
std::fs::read(tmp.path().join("v1").join("a.tar.gz")).unwrap(),
b"two"
);
assert!(std::fs::read_to_string(tmp.path().join("v1").join("a.tar.gz.sha256"))
.unwrap()
.starts_with(&sha2));
}
#[test]
fn oversize_upload_rejected_and_cleaned_up() {
let tmp = TempDir::new().unwrap();
let mut up = ReleaseUpload::begin(tmp.path(), "v1", "big.tar.gz", false, 4).unwrap();
let err = up.write(b"12345").unwrap_err();
assert!(matches!(err, ReleaseError::TooLarge(4)));
drop(up);
// no artifact, no stray temp files
assert!(!tmp.path().join("v1").join("big.tar.gz").exists());
let leftovers: Vec<_> = std::fs::read_dir(tmp.path().join("v1"))
.unwrap()
.collect();
assert!(leftovers.is_empty(), "temp files left behind: {:?}", leftovers);
}
#[test]
fn invalid_names_rejected() {
let tmp = TempDir::new().unwrap();
assert!(matches!(
ReleaseUpload::begin(tmp.path(), "../v1", "a.tar.gz", false, 100),
Err(ReleaseError::InvalidName(_))
));
assert!(matches!(
ReleaseUpload::begin(tmp.path(), "v1", "../../a", false, 100),
Err(ReleaseError::InvalidName(_))
));
assert!(matches!(
delete_release(tmp.path(), "..", None),
Err(ReleaseError::InvalidName(_))
));
}
#[test]
fn list_orders_versions_newest_first() {
let tmp = TempDir::new().unwrap();
upload(tmp.path(), "v1.0.0", "a.tar.gz", b"aaa", false).unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
upload(tmp.path(), "v1.1.0", "b.tar.gz", b"bbb", false).unwrap();
let index = list_releases(tmp.path()).unwrap();
assert_eq!(index.versions.len(), 2);
assert_eq!(index.versions[0].version, "v1.1.0");
assert_eq!(index.versions[1].version, "v1.0.0");
assert_eq!(index.versions[0].files.len(), 1);
assert_eq!(index.versions[0].files[0].name, "b.tar.gz");
assert_eq!(index.versions[0].files[0].size, 3);
assert_eq!(index.versions[0].files[0].sha256.len(), 64);
assert!(!index.versions[0].published.is_empty());
}
#[test]
fn list_missing_dir_is_empty() {
let tmp = TempDir::new().unwrap();
let index = list_releases(&tmp.path().join("nope")).unwrap();
assert!(index.versions.is_empty());
}
#[test]
fn list_excludes_sha256_companions() {
let tmp = TempDir::new().unwrap();
upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
let index = list_releases(tmp.path()).unwrap();
let names: Vec<_> = index.versions[0].files.iter().map(|f| &f.name).collect();
assert_eq!(names, vec!["a.tar.gz"]);
}
#[test]
fn delete_file_and_version() {
let tmp = TempDir::new().unwrap();
upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
upload(tmp.path(), "v1", "b.tar.gz", b"y", false).unwrap();
delete_release(tmp.path(), "v1", Some("a.tar.gz")).unwrap();
assert!(!tmp.path().join("v1").join("a.tar.gz").exists());
assert!(!tmp.path().join("v1").join("a.tar.gz.sha256").exists());
assert!(tmp.path().join("v1").exists());
// deleting the last file removes the version dir
delete_release(tmp.path(), "v1", Some("b.tar.gz")).unwrap();
assert!(!tmp.path().join("v1").exists());
}
#[test]
fn delete_whole_version_and_missing_targets_error() {
let tmp = TempDir::new().unwrap();
upload(tmp.path(), "v1", "a.tar.gz", b"x", false).unwrap();
delete_release(tmp.path(), "v1", None).unwrap();
assert!(!tmp.path().join("v1").exists());
assert!(matches!(
delete_release(tmp.path(), "v1", None),
Err(ReleaseError::NotFound(_))
));
assert!(matches!(
delete_release(tmp.path(), "v2", Some("z.tar.gz")),
Err(ReleaseError::NotFound(_))
));
}
}
```
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).
- [ ] **Step 3: Run tests to verify they fail**
Run: `cargo test --bin git-collab-server releases::`
Expected: FAIL (panics at `todo!()`)
- [ ] **Step 4: Implement the store**
Replace the three `todo!()` bodies:
```rust
pub fn begin(
releases_dir: &Path,
version: &str,
filename: &str,
force: bool,
max_size: u64,
) -> Result<Self, ReleaseError> {
if !validate_name(version) {
return Err(ReleaseError::InvalidName(version.to_string()));
}
if !validate_name(filename) {
return Err(ReleaseError::InvalidName(filename.to_string()));
}
let version_dir = releases_dir.join(version);
std::fs::create_dir_all(&version_dir)?;
let dest = version_dir.join(filename);
if dest.exists() && !force {
return Err(ReleaseError::AlreadyExists(format!("{}/{}", version, filename)));
}
let temp = NamedTempFile::new_in(&version_dir)?;
let sha_dest = version_dir.join(format!("{}.sha256", filename));
Ok(Self {
temp,
hasher: Sha256::new(),
written: 0,
max_size,
dest,
sha_dest,
filename: filename.to_string(),
force,
})
}
pub fn write(&mut self, chunk: &[u8]) -> Result<(), ReleaseError> {
self.written += chunk.len() as u64;
if self.written > self.max_size {
return Err(ReleaseError::TooLarge(self.max_size));
}
self.hasher.update(chunk);
self.temp.write_all(chunk)?;
Ok(())
}
pub fn finish(mut self) -> Result<String, ReleaseError> {
self.temp.flush()?;
let digest = self.hasher.finalize();
let hex: String = digest.iter().map(|b| format!("{:02x}", b)).collect();
if self.force {
self.temp
.persist(&self.dest)
.map_err(|e| ReleaseError::Io(e.error))?;
} else {
self.temp
.persist_noclobber(&self.dest)
.map_err(|e| ReleaseError::Io(e.error))?;
}
std::fs::write(&self.sha_dest, format!("{} {}\n", hex, self.filename))?;
Ok(hex)
}
```
```rust
pub fn list_releases(releases_dir: &Path) -> Result<ReleaseIndex, ReleaseError> {
let mut versions = Vec::new();
let read_dir = match std::fs::read_dir(releases_dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(ReleaseIndex { versions })
}
Err(e) => return Err(e.into()),
};
for entry in read_dir {
let entry = entry?;
if !entry.path().is_dir() {
continue;
}
let version = entry.file_name().to_string_lossy().to_string();
if !validate_name(&version) {
continue;
}
let mtime = entry
.metadata()?
.modified()
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let published = chrono::DateTime::<chrono::Utc>::from(mtime)
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let mut files = Vec::new();
for file_entry in std::fs::read_dir(entry.path())? {
let file_entry = file_entry?;
let name = file_entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || name.ends_with(".sha256") || !file_entry.path().is_file() {
continue;
}
let size = file_entry.metadata()?.len();
let sha256 = std::fs::read_to_string(
entry.path().join(format!("{}.sha256", name)),
)
.ok()
.and_then(|s| s.split_whitespace().next().map(|t| t.to_string()))
.unwrap_or_default();
files.push(ReleaseFile { name, size, sha256 });
}
files.sort_by(|a, b| a.name.cmp(&b.name));
versions.push((mtime, ReleaseVersion { version, published, files }));
}
versions.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.version.cmp(&b.1.version)));
Ok(ReleaseIndex {
versions: versions.into_iter().map(|(_, v)| v).collect(),
})
}
pub fn delete_release(
releases_dir: &Path,
version: &str,
filename: Option<&str>,
) -> Result<(), ReleaseError> {
if !validate_name(version) {
return Err(ReleaseError::InvalidName(version.to_string()));
}
let version_dir = releases_dir.join(version);
if !version_dir.is_dir() {
return Err(ReleaseError::NotFound(version.to_string()));
}
match filename {
None => {
std::fs::remove_dir_all(&version_dir)?;
}
Some(name) => {
if !validate_name(name) {
return Err(ReleaseError::InvalidName(name.to_string()));
}
let file = version_dir.join(name);
if !file.is_file() {
return Err(ReleaseError::NotFound(format!("{}/{}", version, name)));
}
std::fs::remove_file(&file)?;
let _ = std::fs::remove_file(version_dir.join(format!("{}.sha256", name)));
let is_empty = std::fs::read_dir(&version_dir)?.next().is_none();
if is_empty {
std::fs::remove_dir(&version_dir)?;
}
}
}
Ok(())
}
```
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(_)))`.
- [ ] **Step 5: Run tests to verify they pass**
Run: `cargo test --bin git-collab-server releases::`
Expected: PASS (10 tests)
- [ ] **Step 6: Commit**
```bash
git add Cargo.toml Cargo.lock src/server/releases.rs src/server/main.rs
git commit -m "Add server-side release artifact store"
```
---
### Task 3: `max_release_size` server config
**Files:**
- Modify: `src/server/config.rs`
- Modify: `src/server/ssh/session.rs:16-20` (`SshServerConfig`)
- Modify: `src/server/main.rs:66-69` (wiring)
- [ ] **Step 1: Write the failing test**
In `src/server/config.rs` tests, add:
```rust
#[test]
fn parse_max_release_size() {
let toml = r#"
repos_dir = "/srv/git"
authorized_keys = "/keys"
max_release_size = 1024
"#;
let config = ServerConfig::from_toml(toml).unwrap();
assert_eq!(config.max_release_size, 1024);
}
```
And extend the existing `parse_minimal_config_uses_defaults` test with:
```rust
assert_eq!(config.max_release_size, 1024 * 1024 * 1024);
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test --bin git-collab-server config::`
Expected: FAIL (no field `max_release_size`)
- [ ] **Step 3: Implement**
In `ServerConfig`, add the field:
```rust
#[serde(default = "default_max_release_size")]
pub max_release_size: u64,
```
and next to the other default fns:
```rust
fn default_max_release_size() -> u64 {
crate::releases::DEFAULT_MAX_RELEASE_SIZE
}
```
In `src/server/ssh/session.rs`, extend `SshServerConfig`:
```rust
#[derive(Debug, Clone)]
pub struct SshServerConfig {
pub repos_dir: PathBuf,
pub authorized_keys_path: PathBuf,
pub max_release_size: u64,
}
```
In `src/server/main.rs`, extend the `SshServerConfig` construction:
```rust
let ssh_config = ssh::session::SshServerConfig {
repos_dir: config.repos_dir.clone(),
authorized_keys_path: config.authorized_keys.clone(),
max_release_size: config.max_release_size,
};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --bin git-collab-server config::`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/server/config.rs src/server/ssh/session.rs src/server/main.rs
git commit -m "Add max_release_size server config option"
```
---
### Task 4: Parse `collab-release` exec commands
**Execution deviations:**
- `parse_exec_command`: added an explicit empty-token check (`verb.is_empty() || rest.iter().any(|a| a.is_empty())`) after collecting `rest`, so `collab-release list ''` etc. are rejected instead of producing a `ReleaseCmd` with an empty repo/version/filename.
- `shell_tokens`: switched the whitespace test from `c.is_whitespace()` to `c == ' ' || c == '\t'`, so only ASCII space/tab split tokens (tighter grammar on an allowlist gate; no real ssh client sends Unicode separators here).
**Files:**
- Modify: `src/server/ssh/session.rs` (new enum + parser + tests; keep `parse_git_command` as-is)
- [ ] **Step 1: Write the failing tests**
Add to the `tests` module in `src/server/ssh/session.rs`:
```rust
#[test]
fn parse_release_upload() {
let cmd = parse_exec_command("collab-release upload 'myrepo.git' 'v1.0.0' 'app.tar.gz'");
assert_eq!(
cmd,
Some(ExecCommand::Release(ReleaseCmd::Upload {
repo: "myrepo.git".into(),
version: "v1.0.0".into(),
filename: "app.tar.gz".into(),
force: false,
}))
);
}
#[test]
fn parse_release_upload_force() {
let cmd =
parse_exec_command("collab-release upload 'myrepo.git' 'v1' 'a.tar.gz' --force");
assert_eq!(
cmd,
Some(ExecCommand::Release(ReleaseCmd::Upload {
repo: "myrepo.git".into(),
version: "v1".into(),
filename: "a.tar.gz".into(),
force: true,
}))
);
}
#[test]
fn parse_release_list_and_delete() {
assert_eq!(
parse_exec_command("collab-release list 'myrepo.git'"),
Some(ExecCommand::Release(ReleaseCmd::List {
repo: "myrepo.git".into()
}))
);
assert_eq!(
parse_exec_command("collab-release delete 'myrepo.git' 'v1'"),
Some(ExecCommand::Release(ReleaseCmd::Delete {
repo: "myrepo.git".into(),
version: "v1".into(),
filename: None,
}))
);
assert_eq!(
parse_exec_command("collab-release delete 'myrepo.git' 'v1' 'a.tar.gz'"),
Some(ExecCommand::Release(ReleaseCmd::Delete {
repo: "myrepo.git".into(),
version: "v1".into(),
filename: Some("a.tar.gz".into()),
}))
);
}
#[test]
fn parse_exec_command_handles_git_commands() {
assert_eq!(
parse_exec_command("git-upload-pack '/srv/git/repo.git'"),
Some(ExecCommand::Git {
cmd: "git-upload-pack".into(),
repo: "/srv/git/repo.git".into()
})
);
}
#[test]
fn parse_release_rejects_malformed() {
assert_eq!(parse_exec_command("collab-release"), None);
assert_eq!(parse_exec_command("collab-release frobnicate 'r'"), None);
assert_eq!(parse_exec_command("collab-release upload 'r'"), None);
assert_eq!(parse_exec_command("collab-release upload 'r' 'v'"), None);
assert_eq!(
parse_exec_command("collab-release upload 'r' 'v' 'f' --frob"),
None
);
assert_eq!(parse_exec_command("collab-release list 'r' extra"), None);
assert_eq!(parse_exec_command("collab-release upload 'unclosed"), None);
assert_eq!(parse_exec_command("rm -rf /"), None);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --bin git-collab-server session::`
Expected: FAIL to compile (no `parse_exec_command`)
- [ ] **Step 3: Implement**
Add below `parse_git_command` in `session.rs`:
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecCommand {
Git { cmd: String, repo: String },
Release(ReleaseCmd),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReleaseCmd {
Upload {
repo: String,
version: String,
filename: String,
force: bool,
},
List {
repo: String,
},
Delete {
repo: String,
version: String,
filename: Option<String>,
},
}
impl ReleaseCmd {
pub fn repo(&self) -> &str {
match self {
ReleaseCmd::Upload { repo, .. }
| ReleaseCmd::List { repo }
| ReleaseCmd::Delete { repo, .. } => repo,
}
}
}
/// Split an exec string into tokens, honoring single/double quotes.
/// Returns None on unbalanced quotes. No escape sequences (release names
/// have a restricted charset; git paths never need them here either).
fn shell_tokens(input: &str) -> Option<Vec<String>> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_token = false;
let mut quote: Option<char> = None;
for c in input.trim().chars() {
match quote {
Some(q) if c == q => quote = None,
Some(_) => current.push(c),
None if c == '\'' || c == '"' => {
quote = Some(c);
in_token = true;
}
None if c.is_whitespace() => {
if in_token {
tokens.push(std::mem::take(&mut current));
in_token = false;
}
}
None => {
current.push(c);
in_token = true;
}
}
}
if quote.is_some() {
return None;
}
if in_token {
tokens.push(current);
}
Some(tokens)
}
/// Parse an SSH exec request into an allowed command, or None if rejected.
pub fn parse_exec_command(data: &str) -> Option<ExecCommand> {
if let Some((cmd, repo)) = parse_git_command(data) {
return Some(ExecCommand::Git {
cmd: cmd.to_string(),
repo: repo.to_string(),
});
}
let tokens = shell_tokens(data)?;
let mut it = tokens.into_iter();
if it.next()? != "collab-release" {
return None;
}
let verb = it.next()?;
let rest: Vec<String> = it.collect();
match (verb.as_str(), rest.as_slice()) {
("upload", [repo, version, filename]) => Some(ExecCommand::Release(ReleaseCmd::Upload {
repo: repo.clone(),
version: version.clone(),
filename: filename.clone(),
force: false,
})),
("upload", [repo, version, filename, flag]) if flag == "--force" => {
Some(ExecCommand::Release(ReleaseCmd::Upload {
repo: repo.clone(),
version: version.clone(),
filename: filename.clone(),
force: true,
}))
}
("list", [repo]) => Some(ExecCommand::Release(ReleaseCmd::List { repo: repo.clone() })),
("delete", [repo, version]) => Some(ExecCommand::Release(ReleaseCmd::Delete {
repo: repo.clone(),
version: version.clone(),
filename: None,
})),
("delete", [repo, version, filename]) => Some(ExecCommand::Release(ReleaseCmd::Delete {
repo: repo.clone(),
version: version.clone(),
filename: Some(filename.clone()),
})),
_ => None,
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --bin git-collab-server session::`
Expected: PASS (all old + 5 new tests)
- [ ] **Step 5: Commit**
```bash
git add src/server/ssh/session.rs
git commit -m "Parse collab-release SSH exec commands"
```
---
### Task 5: SSH client support in the test harness
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.
**Files:**
- Modify: `tests/common/mod.rs` (`ServerHarness`)
- Create: `tests/release_server_test.rs`
- [ ] **Step 1: Add harness support**
In `tests/common/mod.rs`:
1. Add `ssh_addr: SocketAddr` to `struct ServerHarness` (after `http_addr`).
2. In `ServerHarness::new`, replace the body with a delegation and keep behavior identical:
```rust
pub fn new(repo_name: &str) -> Self {
Self::new_with_extra_config(repo_name, "")
}
/// Like `new`, but appends extra lines to the server config
/// (e.g. "max_release_size = 1024").
pub fn new_with_extra_config(repo_name: &str, extra_config: &str) -> Self {
// ... existing body of new() ...
}
```
Inside, change the config write to append the extra lines and stop discarding the SSH address:
```rust
std::fs::write(
&config_path,
format!(
"repos_dir = {:?}\nhttp_bind = \"{}\"\nssh_bind = \"{}\"\nauthorized_keys = {:?}\nsite_title = \"git-collab test\"\n{}",
repos_dir,
http_addr,
ssh_addr,
authorized_keys,
extra_config,
),
)
.unwrap();
```
and construct with `ssh_addr` included:
```rust
let mut harness = Self {
root,
repo_name: repo_name.to_string(),
work_repo,
server,
http_addr,
ssh_addr,
};
```
3. Add the SSH helper methods to `impl ServerHarness`:
```rust
/// Path to the server's repos dir (for on-disk assertions).
pub fn repos_dir(&self) -> PathBuf {
self.root.path().join("repos")
}
/// Generate a client SSH keypair (once) and authorize it. Returns the key path.
pub fn ssh_client_key(&self) -> PathBuf {
let key_path = self.root.path().join("id_ed25519");
if !key_path.exists() {
let output = Command::new("ssh-keygen")
.args(["-t", "ed25519", "-N", "", "-q", "-f", key_path.to_str().unwrap()])
.output()
.expect("failed to run ssh-keygen");
assert!(
output.status.success(),
"ssh-keygen failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let pubkey =
std::fs::read_to_string(key_path.with_extension("pub")).unwrap();
std::fs::write(self.root.path().join("authorized_keys"), pubkey).unwrap();
}
key_path
}
/// The ssh client options needed to reach this test server, as a single
/// command string usable both directly and as GIT_COLLAB_SSH_COMMAND.
pub fn ssh_command_string(&self) -> String {
format!(
"ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o BatchMode=yes",
self.ssh_client_key().display()
)
}
/// Run a remote command over SSH with the given stdin bytes.
pub fn ssh_exec_with_stdin(&self, remote_cmd: &str, stdin: &[u8]) -> Output {
let key = self.ssh_client_key();
let mut child = Command::new("ssh")
.args([
"-p",
&self.ssh_addr.port().to_string(),
"-i",
key.to_str().unwrap(),
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"IdentitiesOnly=yes",
"-o",
"BatchMode=yes",
"git@127.0.0.1",
remote_cmd,
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn ssh");
child
.stdin
.take()
.unwrap()
.write_all(stdin)
.expect("failed to write ssh stdin");
// stdin handle drops here -> EOF on the channel
child.wait_with_output().expect("failed to wait for ssh")
}
pub fn ssh_exec(&self, remote_cmd: &str) -> Output {
self.ssh_exec_with_stdin(remote_cmd, b"")
}
/// ssh:// URL for the harness repo, for use as a git-collab remote.
pub fn repo_ssh_url(&self) -> String {
format!(
"ssh://git@127.0.0.1:{}/{}.git",
self.ssh_addr.port(),
self.repo_name
)
}
```
4. Add a binary-safe HTTP GET (below `get`):
```rust
/// Like `get`, but returns the raw body bytes and full header block.
pub fn get_bytes(&self, path: &str) -> (String, Vec<u8>) {
let mut stream = TcpStream::connect(self.http_addr).unwrap();
stream
.write_all(
format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
path, self.http_addr
)
.as_bytes(),
)
.unwrap();
let mut raw = Vec::new();
stream.read_to_end(&mut raw).unwrap();
let split = raw
.windows(4)
.position(|w| w == b"\r\n\r\n")
.expect("no header/body separator");
let head = String::from_utf8_lossy(&raw[..split]).to_string();
(head, raw[split + 4..].to_vec())
}
```
- [ ] **Step 2: Write the canary test**
Create `tests/release_server_test.rs`:
```rust
mod common;
use common::ServerHarness;
#[test]
fn ssh_client_interop_rejects_unknown_command() {
let harness = ServerHarness::new("release-canary");
harness.push_head();
let output = harness.ssh_exec("frobnicate");
assert!(
!output.status.success(),
"unknown exec command must fail, got: {}",
String::from_utf8_lossy(&output.stdout)
);
}
```
- [ ] **Step 3: Run the canary test**
Run: `cargo test --test release_server_test`
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).
- [ ] **Step 4: Verify existing suites still pass**
Run: `cargo test --test server_behavior_test`
Expected: PASS (harness refactor is behavior-preserving)
- [ ] **Step 5: Commit**
```bash
git add tests/common/mod.rs tests/release_server_test.rs
git commit -m "Add SSH client support to server test harness"
```
---### Task 6: SSH release operations end-to-end
TDD at the e2e level: write the SSH behavior tests first (they fail because the server rejects `collab-release`), then implement the handler dispatch.
**Files:**
- Modify: `tests/release_server_test.rs` (tests first)
- Modify: `src/server/ssh/session.rs` (handler implementation)
- [ ] **Step 1: Write the failing e2e tests**
Append to `tests/release_server_test.rs`:
```rust
use std::process::Output;
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn assert_ssh_error(output: &Output, needle: &str) {
assert!(!output.status.success(), "expected failure, got success");
let all = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(all.contains(needle), "expected '{}' in output: {}", needle, all);
}
#[test]
fn upload_stores_file_with_checksum() {
let harness = ServerHarness::new("release-upload");
harness.push_head();
let content = b"fake tarball bytes";
let output = harness.ssh_exec_with_stdin(
"collab-release upload 'release-upload.git' 'v1.0.0' 'app.tar.gz'",
content,
);
assert!(output.status.success(), "upload failed: {:?}", output);
// reply is "ok <sha256>"
let reply = stdout(&output);
let sha = reply.trim().strip_prefix("ok ").expect("reply not 'ok <sha>'");
use sha2::Digest;
let expected: String = sha2::Sha256::digest(content)
.iter()
.map(|b| format!("{:02x}", b))
.collect();
assert_eq!(sha, expected);
let stored = harness
.repos_dir()
.join("release-upload.git/collab/releases/v1.0.0/app.tar.gz");
assert_eq!(std::fs::read(&stored).unwrap(), content);
assert!(stored.with_file_name("app.tar.gz.sha256").exists());
}
#[test]
fn duplicate_upload_needs_force() {
let harness = ServerHarness::new("release-dup");
harness.push_head();
let cmd = "collab-release upload 'release-dup.git' 'v1' 'a.tar.gz'";
assert!(harness.ssh_exec_with_stdin(cmd, b"one").status.success());
let dup = harness.ssh_exec_with_stdin(cmd, b"two");
assert_ssh_error(&dup, "already exists");
let forced = harness.ssh_exec_with_stdin(
"collab-release upload 'release-dup.git' 'v1' 'a.tar.gz' --force",
b"two",
);
assert!(forced.status.success(), "forced upload failed: {:?}", forced);
let stored = harness
.repos_dir()
.join("release-dup.git/collab/releases/v1/a.tar.gz");
assert_eq!(std::fs::read(&stored).unwrap(), b"two");
}
#[test]
fn list_returns_json_index() {
let harness = ServerHarness::new("release-list");
harness.push_head();
harness.ssh_exec_with_stdin(
"collab-release upload 'release-list.git' 'v1.0.0' 'a.tar.gz'",
b"aaa",
);
let output = harness.ssh_exec("collab-release list 'release-list.git'");
assert!(output.status.success());
let index: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap();
let versions = index["versions"].as_array().unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0]["version"], "v1.0.0");
assert_eq!(versions[0]["files"][0]["name"], "a.tar.gz");
assert_eq!(versions[0]["files"][0]["size"], 3);
assert_eq!(versions[0]["files"][0]["sha256"].as_str().unwrap().len(), 64);
}
#[test]
fn delete_removes_file_then_version() {
let harness = ServerHarness::new("release-del");
harness.push_head();
harness.ssh_exec_with_stdin("collab-release upload 'release-del.git' 'v1' 'a.tar.gz'", b"a");
harness.ssh_exec_with_stdin("collab-release upload 'release-del.git' 'v1' 'b.tar.gz'", b"b");
let releases = harness.repos_dir().join("release-del.git/collab/releases");
let del_file = harness.ssh_exec("collab-release delete 'release-del.git' 'v1' 'a.tar.gz'");
assert!(del_file.status.success());
assert!(!releases.join("v1/a.tar.gz").exists());
assert!(releases.join("v1/b.tar.gz").exists());
let del_version = harness.ssh_exec("collab-release delete 'release-del.git' 'v1'");
assert!(del_version.status.success());
assert!(!releases.join("v1").exists());
let missing = harness.ssh_exec("collab-release delete 'release-del.git' 'v1'");
assert_ssh_error(&missing, "not found");
}
#[test]
fn write_policy_gates_upload_and_delete_but_not_list() {
let harness = ServerHarness::new("release-policy");
harness.push_head();
harness.write_repo_server_policy(
"visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n",
);
let upload = harness.ssh_exec_with_stdin(
"collab-release upload 'release-policy.git' 'v1' 'a.tar.gz'",
b"x",
);
assert!(!upload.status.success(), "upload must be denied");
let delete = harness.ssh_exec("collab-release delete 'release-policy.git' 'v1'");
assert!(!delete.status.success(), "delete must be denied");
let list = harness.ssh_exec("collab-release list 'release-policy.git'");
assert!(list.status.success(), "list must be allowed for readers");
}
#[test]
fn oversize_upload_rejected_without_partial_file() {
let harness =
ServerHarness::new_with_extra_config("release-size", "max_release_size = 16\n");
harness.push_head();
let output = harness.ssh_exec_with_stdin(
"collab-release upload 'release-size.git' 'v1' 'big.tar.gz'",
&[0u8; 64],
);
assert_ssh_error(&output, "maximum release size");
let version_dir = harness.repos_dir().join("release-size.git/collab/releases/v1");
assert!(!version_dir.join("big.tar.gz").exists());
if version_dir.exists() {
assert_eq!(std::fs::read_dir(&version_dir).unwrap().count(), 0);
}
}
#[test]
fn invalid_names_and_unknown_repo_rejected() {
let harness = ServerHarness::new("release-invalid");
harness.push_head();
let traversal = harness.ssh_exec_with_stdin(
"collab-release upload 'release-invalid.git' '../evil' 'a.tar.gz'",
b"x",
);
assert!(!traversal.status.success());
let unknown = harness.ssh_exec_with_stdin(
"collab-release upload 'nope.git' 'v1' 'a.tar.gz'",
b"x",
);
assert!(!unknown.status.success());
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --test release_server_test`
Expected: canary PASSes; all new tests FAIL (server rejects `collab-release` as unknown command)
- [ ] **Step 3: Implement the SSH handler dispatch**
In `src/server/ssh/session.rs`:
1. Extend the handler struct and constructor:
```rust
pub struct SshHandler {
config: Arc<SshServerConfig>,
authenticated_principal: Option<String>,
/// Sender for forwarding client data (stdin) to the spawned git subprocess.
stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
/// In-progress release upload, fed by data() and finalized on channel EOF.
active_upload: Option<UploadSession>,
}
struct UploadSession {
channel: ChannelId,
state: UploadState,
}
enum UploadState {
Active(crate::releases::ReleaseUpload),
Failed(String),
}
```
(add `active_upload: None` in `SshHandler::new`.)
2. Add a helper for error replies (free function near `run_git_command`):
```rust
fn reply_and_close(session: &mut Session, channel: ChannelId, message: &str, exit_code: u32) {
if !message.is_empty() {
session.data(channel, CryptoVec::from_slice(message.as_bytes()));
}
session.exit_status_request(channel, exit_code);
session.eof(channel);
session.close(channel);
}
```
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:
```rust
let exec_cmd = match parse_exec_command(command_str) {
Some(c) => c,
None => {
warn!("Rejected exec request: not an allowed command");
reply_and_close(session, channel, "", 1);
return Ok(());
}
};
let principal = match self.authenticated_principal.clone() {
Some(principal) => principal,
None => {
warn!("Rejected exec request: not authenticated");
reply_and_close(session, channel, "", 1);
return Ok(());
}
};
let repo_arg = match &exec_cmd {
ExecCommand::Git { repo, .. } => repo.clone(),
ExecCommand::Release(rel) => rel.repo().to_string(),
};
let resolved_path = match resolve_repo_path(&self.config.repos_dir, &repo_arg) {
Some(p) => p,
None => {
warn!("Rejected exec request: path traversal detected");
reply_and_close(session, channel, "error: invalid repo path\n", 1);
return Ok(());
}
};
match exec_cmd {
ExecCommand::Git { cmd: git_cmd, .. } => {
// ... existing logic from `if resolved_path.exists()` through the
// tokio::spawn of run_git_command, unchanged ...
}
ExecCommand::Release(rel) => {
self.handle_release_command(channel, session, rel, &resolved_path, &principal);
}
}
Ok(())
```
4. Add the release handler as a method on `SshHandler` (inside the same `impl SshHandler` block as `new`, NOT the `Handler` trait impl):
```rust
fn handle_release_command(
&mut self,
channel: ChannelId,
session: &mut Session,
rel: ReleaseCmd,
resolved_path: &Path,
principal: &str,
) {
let entry = match crate::repos::entry_for_path(resolved_path) {
Some(entry) => entry,
None => {
warn!("Rejected release command: unknown repo {:?}", resolved_path);
reply_and_close(session, channel, "error: repository not found\n", 1);
return;
}
};
let authorized = match &rel {
ReleaseCmd::List { .. } => entry.policy.allows_read(principal),
ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => {
entry.policy.allows_write(principal)
}
};
if !authorized {
warn!(
"Rejected release command: principal {} not authorized on {:?}",
principal, resolved_path
);
reply_and_close(session, channel, "error: not authorized\n", 1);
return;
}
let dir = crate::releases::releases_dir(&entry);
match rel {
ReleaseCmd::Upload {
version,
filename,
force,
..
} => match crate::releases::ReleaseUpload::begin(
&dir,
&version,
&filename,
force,
self.config.max_release_size,
) {
Ok(upload) => {
self.active_upload = Some(UploadSession {
channel,
state: UploadState::Active(upload),
});
// Reply comes on channel EOF, once all bytes have arrived.
}
Err(e) => {
reply_and_close(session, channel, &format!("error: {}\n", e), 1);
}
},
ReleaseCmd::List { .. } => match crate::releases::list_releases(&dir) {
Ok(index) => {
let json = serde_json::to_string_pretty(&index)
.unwrap_or_else(|_| "{\"versions\":[]}".to_string());
reply_and_close(session, channel, &format!("{}\n", json), 0);
}
Err(e) => {
reply_and_close(session, channel, &format!("error: {}\n", e), 1);
}
},
ReleaseCmd::Delete {
version, filename, ..
} => match crate::releases::delete_release(&dir, &version, filename.as_deref()) {
Ok(()) => {
reply_and_close(session, channel, "deleted\n", 0);
}
Err(e) => {
reply_and_close(session, channel, &format!("error: {}\n", e), 1);
}
},
}
}
```
5. Route upload bytes in `data()` (replace the method body):
```rust
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(upload) = self.active_upload.as_mut() {
if upload.channel == channel {
let state = std::mem::replace(
&mut upload.state,
UploadState::Failed(String::new()),
);
upload.state = match state {
UploadState::Active(mut active) => match active.write(data) {
Ok(()) => UploadState::Active(active),
// Dropping `active` here discards the temp file.
Err(e) => UploadState::Failed(e.to_string()),
},
failed => failed,
};
return Ok(());
}
}
// Forward client data to the git subprocess's stdin
if let Some(ref tx) = self.stdin_tx {
if tx.send(data.to_vec()).await.is_err() {
debug!("stdin channel closed, dropping data");
}
}
Ok(())
}
```
6. Add `channel_eof` to the `Handler` impl:
```rust
async fn channel_eof(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
// Close the git subprocess's stdin, if any.
self.stdin_tx = None;
if let Some(upload) = self.active_upload.take() {
if upload.channel != channel {
self.active_upload = Some(upload);
return Ok(());
}
match upload.state {
UploadState::Active(active) => match active.finish() {
Ok(sha) => reply_and_close(session, channel, &format!("ok {}\n", sha), 0),
Err(e) => {
reply_and_close(session, channel, &format!("error: {}\n", e), 1)
}
},
UploadState::Failed(msg) => {
reply_and_close(session, channel, &format!("error: {}\n", msg), 1)
}
}
}
Ok(())
}
```
Imports to extend at the top of the file: `use super::…` stays; ensure `Path` is already imported (it is, line 1).
- [ ] **Step 4: Run the e2e tests**
Run: `cargo test --test release_server_test`
Expected: PASS (all 8 tests)
Note: `stdin_tx = None` on EOF also affects git commands; verify no regression:
Run: `cargo test --test server_behavior_test --test collab_test`
Expected: PASS
- [ ] **Step 5: Run the full suite**
Run: `cargo test`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/server/ssh/session.rs tests/release_server_test.rs
git commit -m "Handle collab-release upload/list/delete over SSH"
```
**Execution deviations (Task 6):**
- Amendment A applied: `ExecCommand::Git { cmd: GitCmd, repo }` with `enum GitCmd { UploadPack, ReceivePack }` + `as_str()`; `ensure_repo_exists_for_command` and `run_git_command` now take `GitCmd` by value (it is `Copy`), and the authorization match is on the enum, not on strings.
- Amendment B applied: `ExecCommand::repo(&self) -> &str` is used for the `resolve_repo_path` call instead of re-matching.
- Release dispatch is an early return (`let git_cmd = match exec_cmd { Git { cmd, .. } => cmd, Release(rel) => { self.handle_release_command(..); return Ok(()); } };`) rather than wrapping the whole git flow in a `match` arm — behaviorally identical, avoids re-indenting the untouched git flow.
- `UploadState::Active` holds a `Box<ReleaseUpload>` to satisfy clippy's `large_enum_variant`.
- `src/server/ssh/session.rs` and `tests/release_server_test.rs` were run through `rustfmt` (both had pre-existing drift; `src/server/releases.rs` still does and was left alone).
**Review follow-ups (applied after Task 6's first commit):**
- `stdin_tx` is now `Option<(ChannelId, mpsc::Sender<Vec<u8>>)>`: `data()` forwards and `channel_eof` tears down only on a channel match, so an EOF on a release channel can't close a concurrent git push's stdin under SSH connection multiplexing.
- Unauthorized release commands reply with the same `error: repository not found` as unknown repos (distinct `warn!` logs retained), matching the HTTP layer's deliberate 404-collapsing so errors can't probe which private repos exist.
- `write_policy_gates_upload_and_delete_but_not_list` now seeds a release before applying the restrictive policy, and asserts the seed survives (list + on-disk) and the denied upload left nothing — the read-only half was previously vacuous.
- Added `git_push_and_clone_over_ssh` (real `git push`/`git clone` through the harness key), covering `exec_request`'s git branch, stdin forwarding and `channel_eof` end to end. Release e2e file is 9 tests total.
- Added `Handler::channel_close` to drop an unfinished upload (temp cleanup for clients that close without EOF); list serialization failure now replies with an error + exit 1 instead of a silent empty index; the four inline git-branch rejection sequences fold into `reply_and_close`; `GitCmd::as_str(self)` takes self by value; `handle_release_command` documents the accepted synchronous-I/O-on-runtime tradeoff.
- Separate commit: `src/server/main.rs` now `std::process::exit(1)`s when either listener future finishes (a bind failure previously fell out of `main` and exited **0**, which is the flake seen during Task 6). Harness `new_with_extra_config` retries startup once with fresh ports, and readiness failures now include the server's captured stderr. Verified by hand: a busy SSH port yields `exit code 1` + `SSH server error: Address already in use`, and a forced first-attempt port collision is transparently retried.
---
### Task 7: HTTP releases page and downloads
**Execution deviations:**
- `release_download`: per Task 2's note, used `crate::releases::artifact_path(&releases_dir(&entry), &version, &filename)` instead of the plan's manual `validate_name`/`strip_suffix`/`join` block — `Err` maps to the plain 404, `Ok(path)` is opened directly; the `git_collab::release::validate_name` import was dropped from `releases.rs` since it's no longer called there.
**Files:**
- Modify: `tests/release_server_test.rs` (tests first)
- Create: `src/server/http/repo/releases.rs`
- Create: `src/server/http/templates/releases.html`
- Modify: `src/server/http/repo/mod.rs` (module + re-export)
- Modify: `src/server/http/mod.rs` (routes)
- Modify: `src/server/http/templates/repo_base.html` (nav link)
- [ ] **Step 1: Write the failing e2e tests**
Append to `tests/release_server_test.rs`:
```rust
#[test]
fn http_releases_page_and_download() {
let harness = ServerHarness::new("release-http");
harness.push_head();
let content: Vec<u8> = (0u32..600).flat_map(|i| i.to_le_bytes()).collect(); // binary body
harness.ssh_exec_with_stdin(
"collab-release upload 'release-http.git' 'v2.0.0' 'app.tar.gz'",
&content,
);
let page = harness.get_ok("/release-http/releases");
assert!(page.body.contains("v2.0.0"));
assert!(page.body.contains("app.tar.gz"));
assert!(page.body.contains("/release-http/releases/v2.0.0/app.tar.gz"));
let (head, body) = harness.get_bytes("/release-http/releases/v2.0.0/app.tar.gz");
assert!(head.contains("200"), "download failed: {}", head);
assert!(head.to_lowercase().contains("application/octet-stream"));
assert!(head
.to_lowercase()
.contains(&format!("content-length: {}", content.len())));
assert_eq!(body, content);
// checksum companion is downloadable as text
let (sha_head, sha_body) =
harness.get_bytes("/release-http/releases/v2.0.0/app.tar.gz.sha256");
assert!(sha_head.contains("200"));
assert!(String::from_utf8(sha_body).unwrap().contains("app.tar.gz"));
}
#[test]
fn http_release_download_404s() {
let harness = ServerHarness::new("release-http-404");
harness.push_head();
let missing = harness.get("/release-http-404/releases/v9/none.tar.gz");
assert!(missing.status_line.contains("404"));
let traversal = harness.get("/release-http-404/releases/v9/..%2f..%2fconfig");
assert!(!traversal.status_line.contains("200"));
let page = harness.get_ok("/release-http-404/releases");
assert!(page.body.contains("No releases"));
}
#[test]
fn http_releases_respect_repo_policy() {
let harness = ServerHarness::new("release-http-private");
harness.push_head();
harness.ssh_exec_with_stdin(
"collab-release upload 'release-http-private.git' 'v1' 'a.tar.gz'",
b"secret",
);
harness.write_repo_server_policy(
"visibility = \"private\"\n[ui]\nanonymous = false\n[http]\nanonymous_clone = false\n[access]\nread = [\"*\"]\nwrite = [\"*\"]\n",
);
let page = harness.get("/release-http-private/releases");
assert!(page.status_line.contains("404"));
let (head, _) = harness.get_bytes("/release-http-private/releases/v1/a.tar.gz");
assert!(head.contains("404"), "private artifact must not be served: {}", head);
}
```
Note on the `.sha256` filename: `validate_name` allows it (starts alphanumeric, dots allowed), so downloads of companions need no special-casing beyond Content-Type.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --test release_server_test http_`
Expected: FAIL (404 — routes don't exist; `get_ok` panics on the page test)
- [ ] **Step 3: Implement handlers**
Create `src/server/http/repo/releases.rs`:
```rust
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use tokio_util::io::ReaderStream;
use super::{collab_counts, open_repo, AppState};
use crate::releases::{list_releases, releases_dir, ReleaseVersion};
use git_collab::release::validate_name;
#[derive(askama::Template, askama_web::WebTemplate)]
#[template(path = "releases.html")]
pub struct ReleasesTemplate {
pub site_title: String,
pub repo_name: String,
pub active_section: String,
pub open_patches: usize,
pub open_issues: usize,
pub versions: Vec<ReleaseVersion>,
}
pub async fn releases(
Path(repo_name): Path<String>,
State(state): State<Arc<AppState>>,
) -> Response {
let (entry, repo) = match open_repo(&state, &repo_name) {
Ok(pair) => pair,
Err(resp) => return resp,
};
let (open_patches, open_issues) = collab_counts(&repo);
let versions = list_releases(&releases_dir(&entry))
.map(|index| index.versions)
.unwrap_or_default();
ReleasesTemplate {
site_title: state.site_title.clone(),
repo_name,
active_section: "releases".to_string(),
open_patches,
open_issues,
versions,
}
.into_response()
}
pub async fn release_download(
Path((repo_name, version, filename)): Path<(String, String, String)>,
State(state): State<Arc<AppState>>,
) -> Response {
let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) {
Some(e) => e,
None => return plain_404(),
};
// Downloads are data distribution, like clone.
if !entry.policy.allows_anonymous_http() {
return plain_404();
}
// A ".sha256"-suffixed name is "<artifact>.sha256"; validate the artifact part.
let base = filename.strip_suffix(".sha256").unwrap_or(&filename);
if !validate_name(&version) || !validate_name(base) {
return plain_404();
}
let path = releases_dir(&entry).join(&version).join(&filename);
let file = match tokio::fs::File::open(&path).await {
Ok(f) => f,
Err(_) => return plain_404(),
};
let len = match file.metadata().await {
Ok(m) if m.is_file() => m.len(),
_ => return plain_404(),
};
let content_type = if filename.ends_with(".sha256") {
"text/plain; charset=utf-8"
} else {
"application/octet-stream"
};
let mut response = Response::new(axum::body::Body::from_stream(ReaderStream::new(file)));
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type),
);
if let Ok(value) = HeaderValue::from_str(&len.to_string()) {
headers.insert(header::CONTENT_LENGTH, value);
}
response
}
fn plain_404() -> Response {
(StatusCode::NOT_FOUND, "Not found").into_response()
}
```
(The HTML 404 for the releases page comes from `open_repo` internally; downloads use the plain-text 404 above, matching `git_http.rs`.)
Create `src/server/http/templates/releases.html`:
```html
{% extends "repo_base.html" %}
{% block title %}{{ repo_name }} · releases · {{ site_title }}{% endblock %}
{% block content %}
<h2>Releases</h2>
{% if versions.is_empty() %}
<p>No releases.</p>
{% endif %}
{% for v in versions %}
<section>
<h3>{{ v.version }}</h3>
<p>{{ v.published }}</p>
<ul>
{% for f in v.files %}
<li>
<a href="/{{ repo_name }}/releases/{{ v.version }}/{{ f.name }}">{{ f.name }}</a>
({{ f.size }} bytes)
<code>{{ f.sha256 }}</code>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
{% endblock %}
```
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.
In `src/server/http/repo/mod.rs`, add:
```rust
mod releases;
pub use releases::{release_download, releases};
```
In `src/server/http/mod.rs`, add routes (after the `/{repo_name}/issues/{id}` route, before the git routes):
```rust
.route("/{repo_name}/releases", axum::routing::get(repo::releases))
.route(
"/{repo_name}/releases/{version}/{filename}",
axum::routing::get(repo::release_download),
)
```
In `src/server/http/templates/repo_base.html`, add to the nav after the issues link:
```html
<a href="/{{ repo_name }}/releases"{% if active_section == "releases" %} class="active"{% endif %}>releases</a>
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test --test release_server_test`
Expected: PASS (all 11 tests)
Run: `cargo test --test server_behavior_test`
Expected: PASS (nav change must not break existing page tests)
- [ ] **Step 5: Commit**
```bash
git add src/server/http tests/release_server_test.rs
git commit -m "Serve release listings and downloads over HTTP"
```
---
### Task 8: CLI `git-collab release` commands
**Execution deviations:**
- `ssh_command`: dropped the plan's `.to_string()` on `parts.next().unwrap_or("ssh")` when building `Command::new(...)` — clippy's `unnecessary_to_owned` flagged it as a new warning; `Command::new` already accepts `&str`.
**Files:**
- Modify: `src/release.rs` (remote parsing + command execution)
- Modify: `src/cli.rs` (subcommand)
- Modify: `src/lib.rs` (dispatch)
- Create: `tests/release_cli_test.rs`
- [ ] **Step 1: Write failing unit tests for remote URL parsing**
Append to `src/release.rs` (inside the existing `tests` module):
```rust
#[test]
fn parse_ssh_url_full() {
let r = parse_ssh_remote("ssh://git@example.com:2222/myrepo.git").unwrap();
assert_eq!(r.user.as_deref(), Some("git"));
assert_eq!(r.host, "example.com");
assert_eq!(r.port, Some(2222));
assert_eq!(r.path, "myrepo.git");
}
#[test]
fn parse_ssh_url_minimal() {
let r = parse_ssh_remote("ssh://example.com/org/repo.git").unwrap();
assert_eq!(r.user, None);
assert_eq!(r.port, None);
assert_eq!(r.path, "org/repo.git");
}
#[test]
fn parse_scp_style() {
let r = parse_ssh_remote("git@example.com:myrepo.git").unwrap();
assert_eq!(r.user.as_deref(), Some("git"));
assert_eq!(r.host, "example.com");
assert_eq!(r.port, None);
assert_eq!(r.path, "myrepo.git");
}
#[test]
fn parse_rejects_non_ssh() {
assert!(parse_ssh_remote("https://example.com/repo.git").is_none());
assert!(parse_ssh_remote("/srv/git/repo.git").is_none());
assert!(parse_ssh_remote("../relative/path").is_none());
assert!(parse_ssh_remote("file:///srv/git/repo.git").is_none());
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test --lib release::`
Expected: FAIL to compile (no `parse_ssh_remote`)
- [ ] **Step 3: Implement the client module**
Add to `src/release.rs`:
```rust
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use git2::Repository;
use crate::error::Error;
#[derive(Debug, PartialEq, Eq)]
pub struct SshRemote {
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
pub path: String,
}
/// Parse `ssh://[user@]host[:port]/path` or scp-style `[user@]host:path`.
pub fn parse_ssh_remote(url: &str) -> Option<SshRemote> {
if let Some(rest) = url.strip_prefix("ssh://") {
let (authority, path) = rest.split_once('/')?;
let (user, hostport) = split_user(authority);
let (host, port) = match hostport.rsplit_once(':') {
Some((h, p)) => (h.to_string(), Some(p.parse().ok()?)),
None => (hostport.to_string(), None),
};
if host.is_empty() || path.is_empty() {
return None;
}
return Some(SshRemote {
user,
host,
port,
path: path.to_string(),
});
}
if url.contains("://") {
return None;
}
// scp-style: [user@]host:path — but not a local path
let (authority, path) = url.split_once(':')?;
if authority.is_empty() || path.is_empty() || authority.contains('/') {
return None;
}
let (user, host) = split_user(authority);
Some(SshRemote {
user,
host: host.to_string(),
port: None,
path: path.to_string(),
})
}
fn split_user(authority: &str) -> (Option<String>, &str) {
match authority.split_once('@') {
Some((user, host)) => (Some(user.to_string()), host),
None => (None, authority),
}
}
fn ssh_remote(repo: &Repository, remote_name: &str) -> Result<SshRemote, Error> {
let remote = repo
.find_remote(remote_name)
.map_err(|_| Error::Cmd(format!("remote '{}' not found", remote_name)))?;
let url = remote
.url()
.ok_or_else(|| Error::Cmd(format!("remote '{}' has no URL", remote_name)))?;
parse_ssh_remote(url).ok_or_else(|| {
Error::Cmd(format!(
"remote '{}' ({}) is not an SSH remote — releases need an ssh:// or user@host: remote",
remote_name, url
))
})
}
/// Build the ssh invocation for a remote, honoring GIT_COLLAB_SSH_COMMAND
/// (like git's GIT_SSH_COMMAND: extra words become leading arguments).
fn ssh_command(remote: &SshRemote) -> Command {
let base = std::env::var("GIT_COLLAB_SSH_COMMAND").unwrap_or_else(|_| "ssh".to_string());
let mut parts = base.split_whitespace();
let mut cmd = Command::new(parts.next().unwrap_or("ssh").to_string());
for part in parts {
cmd.arg(part);
}
if let Some(port) = remote.port {
cmd.arg("-p").arg(port.to_string());
}
match &remote.user {
Some(user) => cmd.arg(format!("{}@{}", user, remote.host)),
None => cmd.arg(&remote.host),
};
cmd
}
fn run_remote(
remote: &SshRemote,
remote_cmd: &str,
stdin: Stdio,
) -> Result<Output, Error> {
let output = ssh_command(remote)
.arg(remote_cmd)
.stdin(stdin)
.output()
.map_err(|e| Error::Cmd(format!("failed to run ssh: {}", e)))?;
if !output.status.success() {
let msg = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
);
return Err(Error::Cmd(if msg.is_empty() {
"server rejected the command".to_string()
} else {
msg
}));
}
Ok(output)
}
pub fn publish(
repo: &Repository,
remote_name: &str,
version: &str,
files: &[PathBuf],
force: bool,
) -> Result<(), Error> {
if !validate_name(version) {
return Err(Error::Cmd(format!("invalid version name: {}", version)));
}
let remote = ssh_remote(repo, remote_name)?;
for file in files {
let filename = file
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::Cmd(format!("invalid file path: {}", file.display())))?
.to_string();
if !validate_name(&filename) {
return Err(Error::Cmd(format!("invalid filename: {}", filename)));
}
let handle = std::fs::File::open(file)
.map_err(|e| Error::Cmd(format!("cannot open {}: {}", file.display(), e)))?;
let mut remote_cmd = format!(
"collab-release upload '{}' '{}' '{}'",
remote.path, version, filename
);
if force {
remote_cmd.push_str(" --force");
}
let output = run_remote(&remote, &remote_cmd, Stdio::from(handle))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let sha = stdout.trim().strip_prefix("ok ").unwrap_or("").to_string();
println!("Published {}/{} (sha256 {})", version, filename, sha);
}
Ok(())
}
pub fn list(repo: &Repository, remote_name: &str, json: bool) -> Result<(), Error> {
let remote = ssh_remote(repo, remote_name)?;
let remote_cmd = format!("collab-release list '{}'", remote.path);
let output = run_remote(&remote, &remote_cmd, Stdio::null())?;
let stdout = String::from_utf8_lossy(&output.stdout);
if json {
print!("{}", stdout);
return Ok(());
}
let index: serde_json::Value = serde_json::from_str(stdout.trim())?;
let versions = index["versions"].as_array().cloned().unwrap_or_default();
if versions.is_empty() {
println!("No releases.");
return Ok(());
}
for v in &versions {
println!(
"{} ({})",
v["version"].as_str().unwrap_or("?"),
v["published"].as_str().unwrap_or("?")
);
for f in v["files"].as_array().cloned().unwrap_or_default() {
println!(
" {} {} bytes sha256:{}",
f["name"].as_str().unwrap_or("?"),
f["size"].as_u64().unwrap_or(0),
f["sha256"].as_str().unwrap_or("?")
);
}
}
Ok(())
}
pub fn delete(
repo: &Repository,
remote_name: &str,
version: &str,
filename: Option<&str>,
) -> Result<(), Error> {
let remote = ssh_remote(repo, remote_name)?;
let mut remote_cmd = format!("collab-release delete '{}' '{}'", remote.path, version);
if let Some(name) = filename {
remote_cmd.push_str(&format!(" '{}'", name));
}
run_remote(&remote, &remote_cmd, Stdio::null())?;
match filename {
Some(name) => println!("Deleted {}/{}", version, name),
None => println!("Deleted {}", version),
}
Ok(())
}
```
- [ ] **Step 4: Run unit tests**
Run: `cargo test --lib release::`
Expected: PASS (7 tests)
- [ ] **Step 5: Add the CLI subcommand and dispatch**
In `src/cli.rs`, add to `enum Commands` (after `Patch(PatchCmd)`):
```rust
/// Manage release artifacts on the server
#[command(subcommand)]
Release(ReleaseCmd),
```
and add the enum (after `PatchCmd`):
```rust
#[derive(Subcommand)]
pub enum ReleaseCmd {
/// Upload files to a release version on the server
Publish {
/// Release version (e.g. v1.2.0)
version: String,
/// Files to upload
#[arg(required = true)]
files: Vec<std::path::PathBuf>,
/// Replace files that already exist in this version
#[arg(long)]
force: bool,
/// Remote name
#[arg(long, default_value = "origin")]
remote: String,
},
/// List releases on the server
List {
/// Output as JSON
#[arg(long)]
json: bool,
/// Remote name
#[arg(long, default_value = "origin")]
remote: String,
},
/// Delete a release version, or a single file from it
Delete {
/// Release version
version: String,
/// Filename (omit to delete the whole version)
filename: Option<String>,
/// Remote name
#[arg(long, default_value = "origin")]
remote: String,
},
}
```
In `src/lib.rs`:
- extend the `use cli::…` import: `use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd, ReleaseCmd};`
- add to the `match cli.command` in `run()` (after the `Commands::Patch(cmd)` arm):
```rust
Commands::Release(cmd) => match cmd {
ReleaseCmd::Publish {
version,
files,
force,
remote,
} => release::publish(repo, &remote, &version, &files, force),
ReleaseCmd::List { json, remote } => release::list(repo, &remote, json),
ReleaseCmd::Delete {
version,
filename,
remote,
} => release::delete(repo, &remote, &version, filename.as_deref()),
},
```
Run: `cargo build`
Expected: compiles
- [ ] **Step 6: Write CLI end-to-end tests**
Create `tests/release_cli_test.rs`:
```rust
mod common;
use std::process::Output;
use common::ServerHarness;
/// Run `git-collab release …` in the harness work repo against the harness SSH server.
fn release_cmd(harness: &ServerHarness, args: &[&str]) -> Output {
let mut cmd = harness.work_repo().cli_command();
cmd.env("GIT_COLLAB_SSH_COMMAND", harness.ssh_command_string());
cmd.args(["release"]).args(args);
cmd.output().expect("failed to run git-collab release")
}
fn setup(name: &str) -> ServerHarness {
let harness = ServerHarness::new(name);
harness.push_head();
let url = harness.repo_ssh_url();
harness.work_repo().git(&["remote", "add", "srv", &url]);
harness
}
#[test]
fn publish_list_delete_roundtrip() {
let harness = setup("cli-roundtrip");
let tarball = harness.work_repo().dir.path().join("app.tar.gz");
std::fs::write(&tarball, b"cli release bytes").unwrap();
let publish = release_cmd(
&harness,
&["publish", "v1.0.0", tarball.to_str().unwrap(), "--remote", "srv"],
);
assert!(
publish.status.success(),
"publish failed: {}{}",
String::from_utf8_lossy(&publish.stdout),
String::from_utf8_lossy(&publish.stderr)
);
let out = String::from_utf8_lossy(&publish.stdout);
assert!(out.contains("Published v1.0.0/app.tar.gz"));
let list = release_cmd(&harness, &["list", "--remote", "srv"]);
assert!(list.status.success());
assert!(String::from_utf8_lossy(&list.stdout).contains("v1.0.0"));
let list_json = release_cmd(&harness, &["list", "--json", "--remote", "srv"]);
let index: serde_json::Value =
serde_json::from_slice(&list_json.stdout).expect("list --json not valid JSON");
assert_eq!(index["versions"][0]["files"][0]["name"], "app.tar.gz");
let delete = release_cmd(&harness, &["delete", "v1.0.0", "--remote", "srv"]);
assert!(delete.status.success());
let after: serde_json::Value =
serde_json::from_slice(&release_cmd(&harness, &["list", "--json", "--remote", "srv"]).stdout)
.unwrap();
assert_eq!(after["versions"].as_array().unwrap().len(), 0);
}
#[test]
fn duplicate_publish_needs_force_flag() {
let harness = setup("cli-force");
let tarball = harness.work_repo().dir.path().join("a.tar.gz");
std::fs::write(&tarball, b"one").unwrap();
let path = tarball.to_str().unwrap();
assert!(release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"])
.status
.success());
let dup = release_cmd(&harness, &["publish", "v1", path, "--remote", "srv"]);
assert!(!dup.status.success());
assert!(String::from_utf8_lossy(&dup.stderr).contains("already exists"));
std::fs::write(&tarball, b"two").unwrap();
let forced = release_cmd(
&harness,
&["publish", "v1", path, "--force", "--remote", "srv"],
);
assert!(forced.status.success());
}
#[test]
fn non_ssh_remote_is_a_clear_error() {
let harness = setup("cli-bad-remote");
let tarball = harness.work_repo().dir.path().join("a.tar.gz");
std::fs::write(&tarball, b"x").unwrap();
// "origin" is a local filesystem path in the harness
let output = release_cmd(
&harness,
&["publish", "v1", tarball.to_str().unwrap(), "--remote", "origin"],
);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("not an SSH remote"));
}
#[test]
fn invalid_version_rejected_client_side() {
let harness = setup("cli-bad-version");
let tarball = harness.work_repo().dir.path().join("a.tar.gz");
std::fs::write(&tarball, b"x").unwrap();
let output = release_cmd(
&harness,
&["publish", "../evil", tarball.to_str().unwrap(), "--remote", "srv"],
);
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("invalid version"));
}
```
- [ ] **Step 7: Run the CLI tests**
Run: `cargo test --test release_cli_test`
Expected: PASS (4 tests)
- [ ] **Step 8: Commit**
```bash
git add src/release.rs src/cli.rs src/lib.rs tests/release_cli_test.rs
git commit -m "Add git-collab release publish/list/delete CLI"
```
---
### Task 9: Final verification
- [ ] **Step 1: Full test suite**
Run: `cargo test`
Expected: PASS, zero failures
- [ ] **Step 2: Clippy**
Run: `cargo clippy --all-targets`
Expected: no warnings. Fix anything it flags (likely candidates: `format!` in `push_str`, needless clones in the exec dispatch).
- [ ] **Step 3: Commit any lint fixes**
```bash
git add -A
git commit -m "Fix clippy lints in release packages feature"
```
(skip if nothing changed)