//! 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 { 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 { 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"); } }