Watch
0
0
Fork
You've already forked hyperhive
0

feat(#2302): parse agent names into AgentName newtype at dashboard boundary

This commit is contained in:
damocles 2026-07-19 17:46:04 +02:00 • committed by mara
commit cfac917b4d

View file

@ -0,0 +1,103 @@
//! Validated identifier newtypes for dashboard path-params — "parse, don't
//! validate" (#2302).
//!
//! [`AgentName`] can only be constructed through a validating parser, so
//! "this string passed the naming whitelist" becomes a compile-time fact the
//! type carries, instead of a convention every handler re-checks against the
//! raw `String`. This is **format** validation only —
//! whether the name refers to a *live* agent is a separate, stateful runtime
//! concern kept at the lookup sites (see `guard_agent_name`), deliberately not
//! folded into the constructor.
use std::fmt;
/// A validated agent name: 1-63 chars of `[a-z0-9_-]`.
///
/// Constructed only via [`AgentName::parse`]. The invariant matches the
/// historical `validate_agent_name` whitelist (conservative, tracking
/// `nixos-container` basename rules and the agent-name convention across the
/// codebase): rejects empty, over-long, uppercase, slashes, dots, and any
/// non-ASCII (incl. unicode homoglyphs of dash / underscore).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AgentName(String);
impl AgentName {
/// Parse + validate a path-param agent name (format only).
///
/// # Errors
/// Returns `Err(reason)` — a caller-ready 400-body string — when `name`
/// is empty, longer than 63 chars, or contains any byte outside
/// `[a-z0-9_-]`.
pub(crate) fn parse(name: &str) -> Result<Self, &'static str> {
if name.is_empty() {
return Err("agent name must not be empty");
}
if name.len() > 63 {
return Err("agent name must be 63 characters or fewer");
}
if !name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
{
return Err("agent name must contain only [a-z0-9_-]");
}
Ok(Self(name.to_owned()))
}
/// The validated name as a string slice.
#[must_use]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AgentName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
use super::AgentName;
#[test]
fn agent_name_accepts_canonical_shapes() {
for ok in [
"damocles",
"hm1nd",
"agent-with-dashes",
"snake_case",
"mixed_2-3",
] {
assert!(AgentName::parse(ok).is_ok(), "should accept {ok:?}");
}
let max = "a".repeat(63);
assert!(AgentName::parse(&max).is_ok(), "63 chars is the boundary");
}
#[test]
fn agent_name_rejects_bad_input() {
let too_long = "a".repeat(64);
for bad in [
"",
&too_long,
"../etc/passwd",
"alice/bob",
"Alice",
"alice bob",
"alice.bob",
"alice;DROP TABLE messages",
// Non-ASCII, incl. unicode homoglyphs of ASCII dash.
"damóclès",
"alice\u{2013}bob", // en-dash
] {
assert!(AgentName::parse(bad).is_err(), "should reject {bad:?}");
}
}
#[test]
fn agent_name_round_trips_as_str() {
assert_eq!(AgentName::parse("damocles").unwrap().as_str(), "damocles");
}
}