Watch
0
0
Fork
You've already forked hyperhive
0

feat(#2302): fold is_plain_ident into PlainIdent newtype

This commit is contained in:
damocles 2026-07-19 17:56:55 +02:00 • committed by mara
commit d4e91bfeeb

View file

@ -1,5 +1,6 @@
//! Validated identifier newtypes for dashboard path-params — "parse, don't
//! validate" (#2302).
//! 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
@ -8,6 +9,11 @@
//! 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;
@ -57,9 +63,52 @@ impl fmt::Display for AgentName {
}
}
/// 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;
use super::{AgentName, PlainIdent};
#[test]
fn agent_name_accepts_canonical_shapes() {
@ -100,4 +149,25 @@ mod tests {
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");
}
}