//! 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`. /// The environment variable nix uses to hand this process the blacklist of /// names that already mean something to the message layer. /// /// **Nix owns the list**, not this crate: `nix/reserved-names.nix` is the one /// copy, and it reaches `hive-c0re`, `swarm-controller`, the swarm collector's /// own assertion and the test suite from that single file. Keeping the /// blacklist current is therefore a config change, not a rebuild of a binary /// — and there is no second list to drift. /// /// Deliberately **not** enforced inside [`Ident::parse`]. Parsing runs on /// every read of an already-created name, so rejecting there would make /// existing agents unreadable rather than un-creatable, and it would be a /// refusal — which is a stronger action than the warning this list is /// currently used for. Creation sites call [`is_reserved_name`]; readers do /// not. /// /// ⚠️ An **unset** variable means *this process was not told*, which is not /// the same as *nothing is reserved*. Callers must say so out loud rather /// than silently treating every name as available. pub const RESERVED_NAMES_ENV: &str = "HIVE_RESERVED_NAMES"; /// Split the value of [`RESERVED_NAMES_ENV`] into names. /// /// Whitespace-separated, not JSON, and that is a deliberate departure from /// the structured env vars elsewhere in the tree. Every entry is an [`Ident`], /// whose charset is `[a-z0-9-]` — so whitespace cannot occur *inside* a name /// and the encoding is provably lossless. Paying for a JSON parser here would /// mean either a dependency in a crate whose entire purpose is to have none, /// or a separate copy of the parse in every consumer. #[must_use] pub fn parse_reserved_names(raw: &str) -> Vec<&str> { raw.split_whitespace().collect() } /// The raw value of [`RESERVED_NAMES_ENV`], or `None` when this process was /// never told what the blacklist is. /// /// A **blank** value folds into `None` on purpose. Nix always renders a /// non-empty list, so a variable that is present but empty is a rendering /// fault, not an operator declaring that nothing is reserved — and the two /// must not look the same to a caller whose next move is to warn about it. #[must_use] pub fn reserved_names_raw() -> Option { std::env::var(RESERVED_NAMES_ENV) .ok() .filter(|raw| !raw.trim().is_empty()) } /// Whether `name` is one of the protocol literals in `reserved`. /// /// Call at **creation** sites only. A caller that is reading or routing an /// existing name must not consult this: the name is already in use, and the /// question there is where it goes, not whether it should exist. #[must_use] pub fn is_reserved_name(name: &str, reserved: &[&str]) -> bool { reserved.contains(&name) } /// 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, is_reserved_name, parse_reserved_names}; #[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 reserved_names_are_flagged_and_ordinary_names_are_not() { // A sample, not the real list: the real one lives in nix now, and // what this crate still owns is the *predicate*. The content of the // blacklist is asserted where the env var is readable — see // `hive-sh4re`'s drift test. let reserved = ["operator", "system", "graceful-stop"]; // Presence arm: every entry must actually be reported. for name in reserved { assert!(is_reserved_name(name, &reserved), "{name:?} not reported"); } // Absence arm, and the reason this test can fail: without it a // predicate that always returns `true` passes the loop above. for ok in ["atlas", "damocles", "iris", "operator-2", "sys", "forged"] { assert!(!is_reserved_name(ok, &reserved), "{ok:?} must NOT be"); } // An empty list reserves nothing — the shape a caller gets when the // env var is unset, and the reason a caller must not treat that // case as "nothing is reserved" without saying so. assert!(!is_reserved_name("operator", &[])); } #[test] fn parses_the_env_encoding() { assert_eq!( parse_reserved_names("operator system graceful-stop"), ["operator", "system", "graceful-stop"] ); // Newlines and runs of spaces are what a nix-rendered list looks // like when someone reformats the file; both must fold away. assert_eq!( parse_reserved_names(" operator\n system \n"), ["operator", "system"] ); // Absence and emptiness collapse to the same empty list, which is // why the CALLER, not this function, has to distinguish them. assert!(parse_reserved_names("").is_empty()); // Every name the encoding can carry must survive a round trip // through `Ident::parse`: a blacklist entry that is not a legal // ident is dead weight, since nothing could ever be created with // it. `graceful-stop` is the one that makes this worth asserting — // it is hyphenated, and a charset tightening would retire it. for name in parse_reserved_names("operator system todo graceful-stop") { assert!(Ident::parse(name).is_ok(), "{name:?} is not an ident"); } } #[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" ); } }