//! Foundational shared newtypes for the hyperhive workspace. //! //! A zero-dependency (bar `serde`) leaf crate so every wire-type crate //! (`hive-sh4re`, `hive-host-sock`, `hive-core-agent-sock`) and both binaries //! (`hive-c0re`, `hivectl`) can type their agent-name fields as [`Ident`] //! and get serde-validated parsing at the socket boundary for free — with //! no cross-crate coupling and without growing `hive-sh4re`. /// A validated hive identifier: 1-63 chars of `[a-z0-9-]`. /// /// The single ident type for agent names, forge labels, and matrix / github /// account names — every value that becomes a filesystem path segment or an /// nspawn machine-name component. Constructed only through the validating /// [`Ident::parse`], so "this string passed the naming whitelist" is a fact /// the type carries instead of a convention every call site re-checks against /// a raw `String`. The charset is deliberately conservative — lowercase /// ascii, digits, and hyphen only (no underscore, dot, slash, or non-ASCII) — /// and length-capped, tracking `nixos-container` basename rules and keeping /// `../` traversal, unicode homoglyphs, and unbounded path segments out of /// any path built from it. Deserialization runs the same parse, so a value /// arriving over the wire is validated on the way in. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Ident(String); impl Ident { /// Maximum length in bytes. A cap stops an unbounded operator-supplied /// name from becoming an over-long path segment (a filesystem / `DoS` /// footgun). pub const MAX_LEN: usize = 63; /// Parse + validate an identifier. /// /// # Errors /// Returns `Err(reason)` — a caller-ready message — when `s` is empty, /// longer than [`Ident::MAX_LEN`], or contains any byte outside /// `[a-z0-9-]`. pub fn parse(s: &str) -> Result { if s.is_empty() { return Err("identifier must not be empty"); } if s.len() > Self::MAX_LEN { return Err("identifier must be 63 characters or fewer"); } 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 fn as_str(&self) -> &str { &self.0 } /// Consume into the inner `String`. #[must_use] pub fn into_string(self) -> String { self.0 } } impl std::fmt::Display for Ident { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl AsRef for Ident { fn as_ref(&self) -> &str { &self.0 } } /// Lets an `Ident` key a `HashMap`/`BTreeMap` be looked up with a `&str`. impl std::borrow::Borrow for Ident { fn borrow(&self) -> &str { &self.0 } } impl serde::Serialize for Ident { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.0) } } impl<'de> serde::Deserialize<'de> for Ident { fn deserialize>(deserializer: D) -> Result { use serde::de::Error as _; let s = String::deserialize(deserializer)?; Ident::parse(&s).map_err(D::Error::custom) } } #[cfg(test)] mod ident_tests { use super::Ident; #[test] fn accepts_canonical_shapes() { for ok in [ "damocles", "hm1nd", "agent-with-dashes", "codeberg", "acct-1", ] { assert!(Ident::parse(ok).is_ok(), "should accept {ok:?}"); } assert!( Ident::parse(&"a".repeat(Ident::MAX_LEN)).is_ok(), "63 chars is the boundary" ); } #[test] fn rejects_bad_input() { let too_long = "a".repeat(Ident::MAX_LEN + 1); for bad in [ "", &too_long, "Alice", // uppercase "snake_case", // underscore (tightened out) "alice.bob", // dot "alice/bob", // slash "../etc/passwd", // traversal "damóclès", // non-ASCII "alice\u{2013}b", // en-dash homoglyph ] { assert!(Ident::parse(bad).is_err(), "should reject {bad:?}"); } } #[test] fn round_trips_and_serde_validates() { let id = Ident::parse("damocles").unwrap(); assert_eq!(id.as_str(), "damocles"); // Serialize is transparent (just the inner string). let json = serde_json::to_string(&id).unwrap(); assert_eq!(json, "\"damocles\""); // Deserialize runs the same parse. let back: Ident = serde_json::from_str(&json).unwrap(); assert_eq!(back, id); assert!( serde_json::from_str::("\"BAD_NAME\"").is_err(), "deserialize must reject an invalid ident" ); } }