feat(#1653): optional name param for bash run tool

This commit is contained in:
damocles 2026-06-13 16:08:31 +02:00 committed by mara
commit 9bd51a7440
5 changed files with 119 additions and 8 deletions

View file

@ -20,7 +20,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Result;
use anyhow::{Result, bail};
use tokio::io::AsyncWriteExt as _;
use crate::paths;
@ -122,15 +122,62 @@ fn refresh_loose_ends() {
// Public API used by daemon dispatch
// ---------------------------------------------------------------------------
/// Longest accepted caller-chosen task name.
const MAX_TASK_NAME_LEN: usize = 64;
/// Validate a caller-chosen task name. The name doubles as the task id
/// and therefore as the `<name>.json` filename, so it must be a single
/// safe path segment. Allows ASCII alphanumerics plus `.`, `_`, `-`;
/// rejects empties, over-long names, `.`/`..`, and anything that could
/// escape the tasks dir or collide with the `.json.tmp` scratch suffix.
fn validate_task_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("task name must not be empty");
}
if name.len() > MAX_TASK_NAME_LEN {
bail!("task name too long (max {MAX_TASK_NAME_LEN} chars)");
}
if name == "." || name == ".." {
bail!("task name {name:?} is reserved");
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
{
bail!("task name {name:?} may only contain ASCII letters, digits, '.', '_', '-'");
}
Ok(())
}
/// Submit a new pending task. Returns the task ID.
///
/// When `name` is `Some`, it is validated and used as the task id (so it
/// surfaces in the wake `from`, status lookups, and the loose-ends list).
/// A name may be reused once any prior task of that name has finished;
/// submitting a name whose task is still `Pending`/`Running` is rejected.
/// `None` falls back to the auto-generated timestamp id.
///
/// # Errors
///
/// Returns an error if the tasks directory cannot be created or the
/// task file cannot be written.
pub fn submit_task(cmd: String, timeout_secs: Option<u64>) -> Result<String> {
/// Returns an error if the name is invalid, a task of that name is still
/// running, the tasks directory cannot be created, or the task file
/// cannot be written.
pub fn submit_task(cmd: String, timeout_secs: Option<u64>, name: Option<String>) -> Result<String> {
std::fs::create_dir_all(paths::tasks_dir())?;
let id = new_task_id();
let id = match name {
Some(name) => {
validate_task_name(&name)?;
if let Some(existing) = read_task(&name)
&& matches!(existing.status, TaskStatus::Pending | TaskStatus::Running)
{
bail!(
"a bash task named `{name}` is already running — wait for it to finish or pick another name"
);
}
name
}
None => new_task_id(),
};
let task = TaskFile {
id: id.clone(),
cmd,
@ -506,3 +553,38 @@ pub(crate) async fn send_wake(
}
}
}
#[cfg(test)]
mod tests {
use super::{MAX_TASK_NAME_LEN, validate_task_name};
#[test]
fn accepts_reasonable_names() {
for ok in ["build", "ci-check", "nix_flake.check", "t1", "A.B-C_9"] {
assert!(validate_task_name(ok).is_ok(), "{ok} should be valid");
}
}
#[test]
fn rejects_unsafe_names() {
// Empty, traversal, separators, control/space, and over-long.
for bad in [
"",
".",
"..",
"a/b",
"../escape",
"has space",
"tab\tname",
"slash\\back",
] {
assert!(
validate_task_name(bad).is_err(),
"{bad:?} should be rejected"
);
}
assert!(validate_task_name(&"x".repeat(MAX_TASK_NAME_LEN + 1)).is_err());
// Exactly at the cap is allowed.
assert!(validate_task_name(&"x".repeat(MAX_TASK_NAME_LEN)).is_ok());
}
}