//! The rules every path into the store obeys, whatever kind of secret it //! addresses. //! //! The controller writes and a hive reads, and neither is senior to the other, //! so what they must agree on is stated once here rather than at each call //! site. A *particular* kind of secret builds its path from these pieces in its //! own module — [`crate::matrix`] is the one that exists. use crate::Error; /// The KV v2 mount every swarm secret lives under. /// /// A literal because the nix-side reader spells the same one in /// `glue-matrix-bao-token.nix`; an option nobody sets would be two ways to say /// one thing. pub const MOUNT: &str = "secret"; /// The root under [`MOUNT`] that every swarm secret lives beneath. pub const ROOT: &str = "swarm"; /// Whose secret it is — the second segment of every path. /// /// An enum rather than free strings so the set is closed: the store's grant is /// written against these segments and cannot be reached from Rust, so a /// misspelled kind is a 403 at provision time rather than anything a compiler /// sees. [`Kind::ALL`] exists so a test can enumerate the set instead of /// restating it. /// /// The segment string lives once, on the variant itself /// (`#[strum(serialize = "...")]`), rather than a second time in a /// hand-written `as_str()` match — [`strum::IntoStaticStr`] derives that /// conversion, so the attribute is the only place a segment is spelled. #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] pub enum Kind { /// One agent container's own secrets. #[strum(serialize = "agents")] Agent, /// One hive's secrets, held on behalf of whatever runs there. An identity /// minted per hive rather than per agent lands here even though an agent /// is what uses it. #[strum(serialize = "hives")] Hive, /// One swarm service — the things a swarm runs beside the controller. #[strum(serialize = "services")] Service, /// The controller itself. There is one per swarm, so the name segment /// below it does not vary; the shape stays uniform anyway, because one /// grant pattern over `/` is cheaper than a special case. #[strum(serialize = "controller")] Controller, } impl Kind { /// Every kind, so callers that must cover the whole set can iterate rather /// than restate it — a second list is a list that drifts. pub const ALL: [Kind; 4] = [Kind::Agent, Kind::Hive, Kind::Service, Kind::Controller]; /// What to call the name in an error — singular, because the message reads /// "hive name ... is not a single path segment". #[must_use] pub const fn label(self) -> &'static str { match self { Kind::Agent => "agent", Kind::Hive => "hive", Kind::Service => "service", Kind::Controller => "controller", } } } /// `swarm//` — everything one principal owns, and the only way to /// build the head of a path. /// /// # Errors /// [`Error::PathSegment`] when `name` is not a single segment of /// `[A-Za-z0-9_-]`, which is what keeps one principal's name from addressing /// another's secrets. pub fn principal_prefix(kind: Kind, name: &str) -> Result { checked_segment(kind.label(), name)?; Ok(format!("{ROOT}/{}/{name}", <&str>::from(kind))) } /// A path segment that cannot change the path's shape. /// /// The charset is deliberately narrower than what the store accepts: a `/` /// turns one agent's segment into another agent's directory, and `..` walks /// out of the prefix entirely. Both are names this crate receives from /// elsewhere — an agent name from the topology, an account name from an /// agent's own config — so neither is trusted to be well-formed here. /// /// Public because the same name is also used to build a path **on disk**, and /// that guard must accept exactly what this one does. Two copies of a charset /// are two charsets: they agree until one is edited, and the day they diverge /// is the day a name is legal in the store and not on the filesystem, or the /// reverse. /// /// # Errors /// [`Error::PathSegment`] when `value` is empty or holds anything outside /// `[A-Za-z0-9_-]`. pub fn checked_segment(kind: &'static str, value: &str) -> Result<(), Error> { if value.is_empty() { return Err(Error::PathSegment { kind, value: value.to_owned(), }); } if !value .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { return Err(Error::PathSegment { kind, value: value.to_owned(), }); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_segment_that_could_change_a_paths_shape_is_refused() { // Each of these is a *different* way to reach outside the segment the // caller meant, and the last two are the ones a charset check catches // but a `contains("..")` check does not. for bad in [ "../argus", "atlas/../argus", "atlas/matrix", "a b", "a.b", "", ] { assert!( checked_segment("agent", bad).is_err(), "segment {bad:?} must be refused" ); } } #[test] fn the_legal_charset_is_actually_reachable() { // The control for the test above: if `checked_segment` rejected // everything, the escape cases would pass for the wrong reason. The // charset is the one `hive-priv` accepts for the same names on disk, // so uppercase and underscore have to stay legal here. assert!(checked_segment("agent", "a-b_C9").is_ok()); assert!(checked_segment("account", "d-e_F0").is_ok()); } #[test] fn the_rejected_segment_is_named_in_the_error() { // The caller usually got the name from config and needs to see which // of the two it was. let e = checked_segment("account", "a b").expect_err("a space is not legal"); assert!( matches!(e, Error::PathSegment { kind, ref value } if kind == "account" && value == "a b"), "got {e:?}" ); } #[test] fn the_bao_policy_is_written_against_exactly_these_segments() { // Renaming any of these is a silent 403 at provision time, not a // compile error: the controller's grant spells the mount and root out // in `nix/host-modules/swarm-bao.nix` (`controllerPolicyText`), which // no Rust change can reach. Editing here means editing there, and // `nix/module-eval.nix` asserts the other side. assert_eq!(MOUNT, "secret"); assert_eq!(ROOT, "swarm"); // Iterated, not listed: the grant covers `/*`, so a kind added // without a segment here would be granted by accident rather than by // decision. Spelling each one out is what makes adding a kind a // deliberate edit. let segments: Vec<&str> = Kind::ALL.iter().map(|k| (*k).into()).collect(); assert_eq!(segments, ["agents", "hives", "services", "controller"]); } #[test] fn a_kind_cannot_share_a_segment_or_a_label_with_another() { // Two kinds resolving to one segment would silently merge two // principals' secrets into one directory; two sharing a label would // make the error name the wrong one. for (i, a) in Kind::ALL.iter().enumerate() { for b in &Kind::ALL[i + 1..] { assert_ne!( <&str>::from(*a), <&str>::from(*b), "{a:?} and {b:?} share a segment" ); assert_ne!(a.label(), b.label(), "{a:?} and {b:?} share a label"); } } } #[test] fn a_principal_prefix_refuses_a_name_that_would_escape_it() { // The control for the arm below: a well-formed name really does build. assert_eq!( principal_prefix(Kind::Hive, "alpha").expect("a plain name is legal"), "swarm/hives/alpha" ); let e = principal_prefix(Kind::Hive, "../atlas").expect_err("a traversal is not"); assert!( matches!(e, Error::PathSegment { kind, .. } if kind == "hive"), "the error must name the principal in the singular, got {e:?}" ); } }