a73x

src/release.rs

Ref:   Size: 7.4 KiB   History

//! Client-side release commands and shared release name validation.

use std::path::PathBuf;
use std::process::Stdio;

use git2::Repository;

use crate::error::Error;
use crate::remote_ssh::{run_remote, ssh_remote};

// The SSH plumbing these commands run on lives in `remote_ssh`, shared with
// `lease`. Re-exported because callers and tests reach for it here.
pub use crate::remote_ssh::{parse_ssh_remote, SshRemote};

/// 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 {
    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 == '-')
}

pub fn publish(
    repo: &Repository,
    remote_name: &str,
    version: &str,
    files: &[PathBuf],
    force: bool,
    json: bool,
) -> Result<(), Error> {
    if !validate_name(version) {
        return Err(Error::Cmd(format!("invalid version name: {}", version)));
    }
    let remote = ssh_remote(repo, remote_name)?;
    // Under `--json` the per-file lines are held back and emitted as one value
    // at the end: stdout must carry exactly one thing a caller can parse, and
    // a stream of objects is not that. The prose keeps reporting each file as
    // it lands, which is what a long upload needs.
    let mut published = Vec::new();
    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)));
        }
        // Directories open successfully on Linux; only reads fail. Without
        // this check we would hand the server an EISDIR-failing handle and
        // publish an empty artifact under a real name.
        if !file.is_file() {
            return Err(Error::Cmd(format!(
                "not a regular file: {}",
                file.display()
            )));
        }
        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(repo, &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();
        if json {
            published.push(serde_json::json!({ "name": filename, "sha256": sha }));
        } else {
            println!("Published {}/{} (sha256 {})", version, filename, sha);
        }
    }
    if json {
        println!(
            "{}",
            serde_json::json!({
                "action": "release.publish",
                "version": version,
                "files": published,
            })
        );
    }
    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(repo, &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>,
    json: bool,
) -> Result<(), Error> {
    if !validate_name(version) {
        return Err(Error::Cmd(format!("invalid version name: {}", version)));
    }
    if let Some(name) = filename {
        if !validate_name(name) {
            return Err(Error::Cmd(format!("invalid filename: {}", name)));
        }
    }
    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(repo, &remote, &remote_cmd, Stdio::null())?;
    if json {
        println!(
            "{}",
            serde_json::json!({
                "action": "release.delete",
                "version": version,
                // Null means the whole version went, not "a file with no name".
                "file": filename,
            })
        );
    } else {
        match filename {
            Some(name) => println!("Deleted {}/{}", version, name),
            None => println!("Deleted {}", version),
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn publish_rejects_non_regular_file() {
        // A directory opens fine on Linux; without an explicit check we would
        // stream an EISDIR-failing handle to the server as an empty artifact.
        let tmp = tempfile::TempDir::new().unwrap();
        let repo = Repository::init(tmp.path()).unwrap();
        repo.remote("origin", "ssh://git@example.invalid/repo.git")
            .unwrap();

        let dir_that_looks_like_a_file = tmp.path().join("payload.tar.gz");
        std::fs::create_dir(&dir_that_looks_like_a_file).unwrap();

        let err = publish(
            &repo,
            "origin",
            "v1",
            &[dir_that_looks_like_a_file],
            false,
            false,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("not a regular file"),
            "got: {}",
            err
        );
    }

    #[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)));
    }
}