use anyhow::{bail, Result};
#[cfg(test)]
use std::path::{Path, PathBuf};
pub fn validate_task_id(id: &str) -> Result<()> {
if is_valid_task_id(id) {
return Ok(());
}
bail!(
"Invalid task ID: must be 1-64 alphanumeric/hyphen/underscore chars, starting with alphanumeric"
)
}
pub fn is_valid_task_id(id: &str) -> bool {
if id.is_empty() || id.len() > 64 {
return false;
}
if id.contains('/') || id.contains('\\') || id.contains("..") {
return false;
}
let mut chars = id.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_alphanumeric()
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub fn validate_workgroup_id(id: &str) -> Result<()> {
if id.len() < 4 || !id.starts_with("wg-") {
bail!("Invalid workgroup ID '{id}': must start with 'wg-'");
}
let suffix = &id[3..];
if suffix.is_empty() {
bail!("Invalid workgroup ID '{id}': empty suffix");
}
if suffix.contains('/') || suffix.contains('\\') || suffix.contains("..") {
bail!("Invalid workgroup ID '{id}': path separators and '..' are forbidden");
}
if !suffix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
bail!("Invalid workgroup ID '{id}': only alphanumeric, '-', '_' allowed after 'wg-'");
}
Ok(())
}
pub fn validate_name(name: &str, kind: &str) -> Result<()> {
if name.is_empty() {
bail!("Empty {kind} name");
}
if name.starts_with('-') || name.starts_with('.') {
bail!("Invalid {kind} name '{name}': must not start with '-' or '.'");
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
bail!("Invalid {kind} name '{name}': path separators and '..' are forbidden");
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
bail!(
"Invalid {kind} name '{name}': only alphanumeric, '-', '_', '.' allowed"
);
}
Ok(())
}
pub fn validate_branch_name(branch: &str) -> Result<()> {
if branch.is_empty() {
bail!("Empty branch name");
}
if branch.starts_with('-') {
bail!("Invalid branch name '{branch}': must not start with '-'");
}
if branch.contains("..") {
bail!("Invalid branch name '{branch}': '..' is forbidden");
}
if branch.contains('~') || branch.contains('^') || branch.contains(':') {
bail!("Invalid branch name '{branch}': git revision syntax characters forbidden");
}
if branch.contains('\0') || branch.contains(' ') || branch.contains('\\') {
bail!("Invalid branch name '{branch}': contains unsafe characters");
}
for c in [';', '|', '&', '$', '`', '(', ')', '{', '}', '<', '>', '!', '*', '?'] {
if branch.contains(c) {
bail!("Invalid branch name '{branch}': shell metacharacter '{c}' forbidden");
}
}
Ok(())
}
#[cfg(test)]
pub fn safe_join(base: &Path, component: &str) -> Result<PathBuf> {
if component.contains("..") {
bail!(
"Path traversal blocked: '{}' contains '..'",
component
);
}
let joined = base.join(component);
let normalized = normalize_path(&joined);
let normalized_base = normalize_path(base);
if !normalized.starts_with(&normalized_base) {
bail!(
"Path traversal blocked: '{}' escapes base '{}'",
component,
base.display()
);
}
Ok(normalized)
}
#[cfg(test)]
fn normalize_path(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::CurDir => {}
other => components.push(other),
}
}
components.iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_task_ids() {
assert!(validate_task_id("t-a3f1").is_ok());
assert!(validate_task_id("t-0000").is_ok());
assert!(validate_task_id("t-ffff").is_ok());
assert!(validate_task_id("audit-utilcap").is_ok());
assert!(validate_task_id("fix-lb-cache").is_ok());
assert!(validate_task_id("my_task_1").is_ok());
assert!(validate_task_id("t-custom-name").is_ok());
assert!(validate_task_id("a").is_ok());
}
#[test]
fn invalid_task_ids() {
assert!(validate_task_id("").is_err());
assert!(validate_task_id("../etc").is_err());
assert!(validate_task_id("foo/../bar").is_err());
assert!(validate_task_id("foo\\bar").is_err());
assert!(validate_task_id("bad id").is_err());
assert!(validate_task_id("bad.id").is_err());
assert!(validate_task_id("-bad").is_err());
assert!(validate_task_id(&"a".repeat(65)).is_err());
}
#[test]
fn invalid_task_id_error_is_human_readable() {
assert_eq!(
validate_task_id("bad.id").unwrap_err().to_string(),
"Invalid task ID: must be 1-64 alphanumeric/hyphen/underscore chars, starting with alphanumeric"
);
}
#[test]
fn valid_workgroup_ids() {
assert!(validate_workgroup_id("wg-a3f1").is_ok());
assert!(validate_workgroup_id("wg-0000").is_ok());
assert!(validate_workgroup_id("wg-custom").is_ok());
assert!(validate_workgroup_id("wg-my-feature").is_ok());
assert!(validate_workgroup_id("wg-shared").is_ok());
}
#[test]
fn invalid_workgroup_ids() {
assert!(validate_workgroup_id("").is_err());
assert!(validate_workgroup_id("wg-").is_err());
assert!(validate_workgroup_id("../../etc").is_err());
assert!(validate_workgroup_id("wg-a3f1/../../x").is_err());
assert!(validate_workgroup_id("wg-foo/../bar").is_err());
}
#[test]
fn valid_names() {
assert!(validate_name("codex", "agent").is_ok());
assert!(validate_name("my-agent", "agent").is_ok());
assert!(validate_name("test_writer", "skill").is_ok());
assert!(validate_name("v1.2", "agent").is_ok());
}
#[test]
fn invalid_names() {
assert!(validate_name("", "agent").is_err());
assert!(validate_name("-leading", "agent").is_err());
assert!(validate_name(".hidden", "agent").is_err());
assert!(validate_name("../escape", "agent").is_err());
assert!(validate_name("foo/bar", "agent").is_err());
assert!(validate_name("foo\\bar", "agent").is_err());
assert!(validate_name("foo bar", "agent").is_err());
assert!(validate_name("foo;rm -rf", "agent").is_err());
}
#[test]
fn valid_branch_names() {
assert!(validate_branch_name("feat/my-feature").is_ok());
assert!(validate_branch_name("fix/bug-123").is_ok());
assert!(validate_branch_name("main").is_ok());
assert!(validate_branch_name("v1.0.0").is_ok());
}
#[test]
fn invalid_branch_names() {
assert!(validate_branch_name("").is_err());
assert!(validate_branch_name("-flag").is_err());
assert!(validate_branch_name("feat/../escape").is_err());
assert!(validate_branch_name("branch;rm -rf /").is_err());
assert!(validate_branch_name("branch$(cmd)").is_err());
assert!(validate_branch_name("HEAD^{commit}").is_err());
assert!(validate_branch_name("main~1").is_err());
}
#[test]
fn safe_join_blocks_traversal() {
let base = PathBuf::from("/tmp");
assert!(safe_join(&base, "good-dir").is_ok());
assert!(safe_join(&base, "../etc/passwd").is_err());
assert!(safe_join(&base, "foo/../../etc").is_err());
}
#[test]
fn safe_join_allows_nested() {
let base = PathBuf::from("/tmp");
let result = safe_join(&base, "aid-wt-feat/subdir").unwrap();
assert!(result.starts_with("/tmp") || result.starts_with("/private/tmp"));
}
}