diff --git a/Cargo.lock b/Cargo.lock index db46ddd6..92cf8206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1672,6 +1672,7 @@ version = "0.1.0" dependencies = [ "hive-sh4re", "serde", + "serde_json", ] [[package]] diff --git a/hive-host-sock/Cargo.toml b/hive-host-sock/Cargo.toml index 23407d6f..24152da4 100644 --- a/hive-host-sock/Cargo.toml +++ b/hive-host-sock/Cargo.toml @@ -9,3 +9,6 @@ workspace = true [dependencies] hive-sh4re.workspace = true serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 7bd71ddf..6e72c844 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -51,6 +51,152 @@ pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{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; + + #[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" + ); + } +} + /// Which way to reconcile an agent's config branches /// ([`HostRequest::ReconcileConfigApply`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]