hyperhive/hive-types/src/lib.rs
atlas 27932ec631 types: let nix own the reserved-name blacklist
One list, in nix/reserved-names.nix, handed to everything that needs it
as HIVE_RESERVED_NAMES. Keeping it current becomes a config change
rather than a rebuild, and hive names and agent names -- one namespace
going forward -- are checked against the same file: swarm-otel.nix's
hand-written reservedOwners is gone.

Whitespace-separated rather than JSON, deliberately, unlike the
structured env vars beside it. Every entry is an Ident ([a-z0-9-]), so
whitespace cannot occur inside a name and the encoding is provably
lossless; JSON would mean either a parser dependency in a crate whose
purpose is to have none, or a copy of the parse in every consumer.

An UNSET variable is not "nothing is reserved". Both creation sites log
an error and return a warning saying the check did not run, so a
misconfigured deployment says so instead of silently accepting every
name. A blank value folds into unset: nix always renders a non-empty
list, so present-but-empty is a rendering fault, not a declaration.

Two guards whose subject moved out of their own file now assert their
own case is still in it, because a guard that can be retired by an edit
elsewhere is not a guard:

- swarm-otel.nix asserts reserved-names.nix still contains its
  swarmTierName.
- hive-sh4re's sentinel drift test PANICS when the variable is missing
  rather than skipping -- a drift test that quietly does nothing still
  reports green. checks.nix and devshell.nix both export it so CI and a
  local cargo test agree. Verified as a pair: with the variable set, 8
  tests pass; with it unset, exactly the 4 drift tests fail and the
  unrelated ones still pass.
2026-08-27 16:36:42 +02:00

258 lines
10 KiB
Rust

//! 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<String> {
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<Self, &'static str> {
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<str> 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<str> for Ident {
fn borrow(&self) -> &str {
&self.0
}
}
impl serde::Serialize for Ident {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> serde::Deserialize<'de> for Ident {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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::<Ident>("\"BAD_NAME\"").is_err(),
"deserialize must reject an invalid ident"
);
}
}