Every agent becomes a Forgejo user of the same name, and nothing upstream of `CreateForgeUser` knew what Forgejo refuses: `admin`, `api`, `foo-` or a 41-character name passed name validation and failed one node into provisioning with Forgejo's 422. `nix/reserved-names.nix` gains the 22 reserved usernames of Forgejo v16.0.5 (`models/user/user.go:639-680`) that `[a-z0-9-]` can spell, and the bare `-` (`models/repo/repo.go:67`). The dot and underscore entries are left out, since our charset cannot produce them. The header's admission rule grows a third class — a username the forge refuses — because that is a failure behind the refusal. The shape rules are not literals, so they live in `hive_types::forge_username_violation`: no leading `-`, no `--`, no trailing `-`, at most 40 characters. Beside `is_reserved_name`, not in `Ident::parse`: an `Ident` is also a hive, label, account and subagent name, and parsing runs on every read of an existing name. `create_agent` used to WARN on a reserved name, deliberately: an operator with agents already created under a colliding name would otherwise be unable to re-run creation. That reason is kept, and narrowed to what it protects. A name breaking either rule is now refused with a 400 naming the rule when the name is NOT in the swarm roster, and still only warned about when it is, so re-creating an existing agent keeps working. The roster is read only for a rule-breaking name; when it cannot be read, a new name and an existing one look alike, and this warns as before. The hive-collision warning is unchanged.
320 lines
13 KiB
Rust
320 lines
13 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)
|
|
}
|
|
|
|
/// Forgejo's cap on a username (`MaxSize(40)` on the admin create-user body,
|
|
/// `modules/structs/admin_user.go:14` in v16.0.5) — tighter than
|
|
/// [`Ident::MAX_LEN`], so an agent name can be a valid [`Ident`] and still
|
|
/// never get a forge account.
|
|
pub const FORGE_USERNAME_MAX_LEN: usize = 40;
|
|
|
|
/// The Forgejo username shape rule an [`Ident`]-valid `name` breaks, or
|
|
/// `None`. Forgejo v16.0.5 `modules/validation/helpers.go:97-108`.
|
|
///
|
|
/// The shape half of the forge's refusals; its reserved literals are in
|
|
/// `nix/reserved-names.nix`. Creation sites only, for the reason given at
|
|
/// [`is_reserved_name`] — and not inside [`Ident::parse`], because an
|
|
/// `Ident` is also a hive, label, account and subagent name that never
|
|
/// becomes a forge user.
|
|
#[must_use]
|
|
pub fn forge_username_violation(name: &str) -> Option<&'static str> {
|
|
if name.starts_with('-') {
|
|
Some("must not start with '-'")
|
|
} else if name.contains("--") {
|
|
Some("must not contain '--'")
|
|
} else if name.ends_with('-') {
|
|
Some("must not end with '-'")
|
|
} else if name.len() > FORGE_USERNAME_MAX_LEN {
|
|
Some("must be 40 characters or fewer")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// 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::{
|
|
FORGE_USERNAME_MAX_LEN, Ident, forge_username_violation, 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");
|
|
}
|
|
}
|
|
|
|
/// Each Forgejo shape rule is named on its own, so a refusal tells the
|
|
/// operator which character to change. The 63-char case is an `Ident`
|
|
/// that is still no forge username: the gap between the two caps.
|
|
#[test]
|
|
fn forge_shape_rules_are_each_refused() {
|
|
let over = "a".repeat(FORGE_USERNAME_MAX_LEN + 1);
|
|
let longest_ident = "a".repeat(Ident::MAX_LEN);
|
|
for (bad, rule) in [
|
|
("-", "must not start with '-'"),
|
|
("-agent", "must not start with '-'"),
|
|
("my--agent", "must not contain '--'"),
|
|
("agent-", "must not end with '-'"),
|
|
(over.as_str(), "must be 40 characters or fewer"),
|
|
(longest_ident.as_str(), "must be 40 characters or fewer"),
|
|
] {
|
|
assert!(Ident::parse(bad).is_ok(), "{bad:?} must be a valid Ident");
|
|
assert_eq!(forge_username_violation(bad), Some(rule), "{bad:?}");
|
|
}
|
|
}
|
|
|
|
/// The accept arm: without it, a predicate refusing everything passes
|
|
/// the test above. 40 characters is the boundary, not past it.
|
|
#[test]
|
|
fn forge_shape_rules_accept_ordinary_names() {
|
|
let at_cap = "a".repeat(FORGE_USERNAME_MAX_LEN);
|
|
for ok in ["my-agent", "a", "v3", "a-b-c", at_cap.as_str()] {
|
|
assert_eq!(forge_username_violation(ok), None, "{ok:?}");
|
|
}
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|