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