173 lines
5.7 KiB
Rust
173 lines
5.7 KiB
Rust
//! Validated identifier newtypes for dashboard path-params — the
|
|
//! "parse, don't validate" discipline applied to agent-name / plain-ident
|
|
//! path parameters.
|
|
//!
|
|
//! [`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.
|
|
//!
|
|
//! [`PlainIdent`] is the slightly stricter sibling (no underscore, no length
|
|
//! cap) used for dashboard-provisioned labels / account names — it folds the
|
|
//! two hand-synced `is_plain_ident` copies that used to live in
|
|
//! `extra_forges` + `matrix_accounts` into one parser.
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// A validated plain identifier: one or more chars of `[a-z0-9-]`.
|
|
///
|
|
/// Matches hive-priv's `validate_name_chars` (lowercase ascii + digits +
|
|
/// hyphens, no underscore, no length cap). Used for dashboard-provisioned
|
|
/// labels + account names (extra-forge labels, matrix account names) that
|
|
/// become filesystem path segments, so the same `../` / uppercase / slash
|
|
/// rejects as [`AgentName`] apply — the two differ only in the underscore
|
|
/// (allowed by `AgentName`, not here) and the 63-char cap.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct PlainIdent(String);
|
|
|
|
impl PlainIdent {
|
|
/// Parse + validate a plain identifier.
|
|
///
|
|
/// # Errors
|
|
/// Returns `Err(reason)` when `s` is empty or contains any byte outside
|
|
/// `[a-z0-9-]`.
|
|
pub(crate) fn parse(s: &str) -> Result<Self, &'static str> {
|
|
if s.is_empty() {
|
|
return Err("identifier must not be empty");
|
|
}
|
|
if !s
|
|
.bytes()
|
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
|
{
|
|
return Err("identifier must contain only [a-z0-9-]");
|
|
}
|
|
Ok(Self(s.to_owned()))
|
|
}
|
|
|
|
/// The validated identifier as a string slice.
|
|
#[must_use]
|
|
pub(crate) fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for PlainIdent {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{AgentName, PlainIdent};
|
|
|
|
#[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");
|
|
}
|
|
|
|
#[test]
|
|
fn plain_ident_accepts_labels_and_accounts() {
|
|
for ok in ["codeberg", "catgirl", "my-forge-1", "acct-1"] {
|
|
assert!(PlainIdent::parse(ok).is_ok(), "should accept {ok:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plain_ident_rejects_bad_input() {
|
|
// Underscore is allowed for AgentName but NOT here (matches
|
|
// hive-priv's stricter `validate_name_chars`).
|
|
for bad in ["", "MyForge", "my_forge", "../escape", "a/b", "a.b"] {
|
|
assert!(PlainIdent::parse(bad).is_err(), "should reject {bad:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plain_ident_round_trips_as_str() {
|
|
assert_eq!(PlainIdent::parse("codeberg").unwrap().as_str(), "codeberg");
|
|
}
|
|
}
|