a73x

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)