refactor(#2302): type socket wire fields as ident, validated by serde on deserialize

This commit is contained in:
damocles 2026-07-20 21:21:03 +02:00 committed by mara
commit 84b750fba5
33 changed files with 333 additions and 263 deletions

View file

@ -8,7 +8,5 @@ workspace = true
[dependencies]
hive-sh4re.workspace = true
hive-types.workspace = true
serde.workspace = true
[dev-dependencies]
serde_json.workspace = true

View file

@ -9,6 +9,7 @@
use std::path::PathBuf;
use hive_sh4re::{AgentStatusRow, Approval, jobs};
use hive_types::Ident;
use serde::{Deserialize, Serialize};
// ── Shared hive layout facts ──────────────────────────────────────────────
@ -55,152 +56,6 @@ 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)]
@ -221,23 +76,23 @@ pub enum HostRequest {
/// Create and start a sub-agent container directly, bypassing the
/// approval queue. Privileged-context only. See
/// `docs/approvals.md::Approval kinds (wire shapes)`.
Spawn { name: String },
Spawn { name: Ident },
/// Submit a spawn request for the operator to approve. See
/// `docs/approvals.md::Approval kinds (wire shapes)` (`Spawn`).
RequestSpawn { name: String },
RequestSpawn { name: Ident },
/// Stop a managed container (graceful).
Kill { name: String },
Kill { name: Ident },
/// Tear down a sub-agent container, optionally purging state.
/// See `docs/approvals.md::Destroy semantics`.
Destroy {
name: String,
name: Ident,
#[serde(default)]
purge: bool,
},
/// Stop and start a managed container without rebuilding config.
/// For "kick the container" operations that don't touch the flake or
/// nspawn flags. Mirrors `lifecycle::restart` (kill + start).
Restart { name: String },
Restart { name: Ident },
/// Stop and restart all managed containers in sequence. Convenience
/// wrapper for `hivectl agents restart-all`; iterates the live
/// container list and restarts each one.
@ -265,7 +120,7 @@ pub enum HostRequest {
graceful: bool,
},
/// Apply pending config to a managed container.
Rebuild { name: String },
Rebuild { name: Ident },
/// List managed containers.
List,
/// List managed agents with their full status + technical state
@ -296,8 +151,8 @@ pub enum HostRequest {
/// Validation rules + bind-mount caveat documented in
/// `docs/agent-hierarchy.md::Current state`.
SetParent {
child: String,
new_parent: Option<String>,
child: Ident,
new_parent: Option<Ident>,
},
/// Stop managed containers hive-wide in one operator action
/// (`hivectl stop`): agents plus the selected infra containers. `scope`
@ -327,7 +182,7 @@ pub enum HostRequest {
/// [`HostResponse::messages`]. `password` is resolved by the client
/// (inline flag or stdin) and `None` requests a random throwaway.
MatrixCreateUser {
name: String,
name: Ident,
#[serde(default)]
password: Option<String>,
},
@ -337,11 +192,11 @@ pub enum HostRequest {
/// Promote a matrix user to homeserver admin via the admin API.
/// Uses the daemon's system admin token; `server_name` is discovered
/// from the running homeserver.
MatrixPromoteUser { name: String },
MatrixPromoteUser { name: Ident },
/// Reset a matrix user's password via the admin API and persist the
/// new password to the matrix creds dir so a later token mint can
/// re-login. Returns the outcome in [`HostResponse::messages`].
MatrixResetPassword { name: String },
MatrixResetPassword { name: Ident },
/// Invite a matrix user to the hive Space (default) or a specific
/// `room`. Uses the daemon's admin token; idempotent
/// (already-member / already-invited is a no-op).
@ -357,7 +212,7 @@ pub enum HostRequest {
/// in [`HostResponse::messages`]. `password` is resolved client-side
/// (inline flag or stdin) and only meaningful for non-agent accounts.
ForgeCreateUser {
name: String,
name: Ident,
#[serde(default)]
password: Option<String>,
},
@ -369,7 +224,7 @@ pub enum HostRequest {
/// — never mutates either side. Backs `hivectl forge reconcile-config`
/// (the diff it always shows first).
ReconcileConfigStatus {
agent: String,
agent: Ident,
#[serde(default)]
verbose: bool,
},
@ -380,7 +235,7 @@ pub enum HostRequest {
/// local needs lifting branch protection — resolve via a config PR).
/// Backs `hivectl forge reconcile-config --from <forge|local>`.
ReconcileConfigApply {
agent: String,
agent: Ident,
direction: ReconcileDirection,
},
/// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file
@ -403,7 +258,7 @@ pub enum HostRequest {
/// set-token`. `token` is resolved + non-empty-validated client-side
/// (inline flag or stdin); the daemon just persists it. Read live by
/// the agent's `gh` wrapper / git credential helper — no rebuild needed.
SetAgentGithubToken { agent: String, token: String },
SetAgentGithubToken { agent: Ident, token: String },
/// Turn on btrfs qgroup accounting on the agent-state filesystem, via
/// the privileged helper. Daemon-side equivalent of `hivectl quota
/// enable`. Returns advisory lines in [`HostResponse::messages`].
@ -414,7 +269,7 @@ pub enum HostRequest {
/// bare success — the client prints the confirmation from the value it
/// sent.
QuotaLimit {
name: String,
name: Ident,
#[serde(default)]
limit: Option<u64>,
},
@ -426,27 +281,27 @@ pub enum HostRequest {
/// plain [`HostResponse::error`] so the client can print the enable hint.
QuotaShow {
#[serde(default)]
name: Option<String>,
name: Option<Ident>,
},
/// Migrate an agent's plain state dir to a btrfs subvolume via the
/// privileged helper (`hivectl subvol upgrade`). The agent MUST already
/// be stopped — the client orchestrates stop → this → start. Returns a
/// bare success; the client prints its own progress lines.
UpgradeSubvolume { name: String },
UpgradeSubvolume { name: Ident },
/// Create a read-only btrfs snapshot of an agent's state subvolume
/// (`hivectl subvol snapshot create`). `label` is validated client-side
/// AND by hive-priv. Returns the snapshot's host path in
/// [`HostResponse::messages`].
SnapshotSubvolume { name: String, label: String },
SnapshotSubvolume { name: Ident, label: String },
/// Delete a snapshot created by `SnapshotSubvolume` (`hivectl subvol
/// snapshot delete`). Bare success; the client prints the confirmation.
DeleteSnapshot { name: String, label: String },
DeleteSnapshot { name: Ident, label: String },
/// Export a snapshot to a local file via `btrfs send` (`hivectl subvol
/// snapshot send`). `dest` is a bare filename (hive-priv rejects paths);
/// `parent` names an optional parent snapshot for an incremental send.
/// Returns the written file's host path in [`HostResponse::messages`].
SendSnapshot {
name: String,
name: Ident,
label: String,
#[serde(default)]
parent: Option<String>,