feat(#2302): add validated Ident newtype in hive-host-sock
This commit is contained in:
parent
d4e91bfeeb
commit
384dcae5f4
3 changed files with 150 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1672,6 +1672,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"hive-sh4re",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -9,3 +9,6 @@ workspace = true
|
|||
[dependencies]
|
||||
hive-sh4re.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
|
||||
#[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::<Ident>("\"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)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue