diff --git a/swarm-authelia-bridge/src/main.rs b/swarm-authelia-bridge/src/main.rs index 1561ca7a..ccd4d6c9 100644 --- a/swarm-authelia-bridge/src/main.rs +++ b/swarm-authelia-bridge/src/main.rs @@ -254,6 +254,10 @@ fn refuse(refusal: Refusal) -> Response { async fn handle(state: &AppState, name: String) -> Result { store::validate_username(&name)?; + // After the charset check and before anything touches the store: a + // reserved name is refused on its own terms, not as a side effect of a + // collision with whatever happens to be in the file today. + store::reject_reserved_name(&name)?; let cfg = &state.config; // Held across the whole load → insert → publish sequence, not just the diff --git a/swarm-authelia-bridge/src/store.rs b/swarm-authelia-bridge/src/store.rs index 1d6ad3b4..571ab23e 100644 --- a/swarm-authelia-bridge/src/store.rs +++ b/swarm-authelia-bridge/src/store.rs @@ -57,6 +57,44 @@ pub struct User { pub extra: BTreeMap, } +/// Names an agent may never take, because something else already answers to +/// them. +/// +/// **Derived, not invented** — every entry is a literal that exists in the +/// code today. The first three are broker *recipients* (`send(to: …)`); the +/// rest are message *senders*, and they matter for a reason that is not +/// cosmetic: the harness switches on the sender name, so an agent called +/// `todo` would have its messages interpreted as todo-wakes and one called +/// `system` as helper events. That is a parse, not a display quirk. +/// +/// Sorted so a reader can scan it; the lookup is a linear walk over eight +/// short strings on a path that already reads a file off disk. +pub const RESERVED_AGENT_NAMES: &[&str] = &[ + "forge", "manager", "operator", "reminder", "root", "schedule", "system", "todo", +]; + +/// Refuse a name the swarm has already given a meaning. +/// +/// ⚠️ **Deliberately NOT part of [`validate_username`].** That function runs +/// from [`validate`] over *every* user on *every* write, including humans +/// this bridge did not create. Folding the reserved list into it would mean +/// that a store already containing an operator called `operator` could never +/// be written again — a rule about what an agent may be *named* would have +/// become a rule about what the file may *contain*, and bricked it. +/// +/// So this is called from the one path that names a new agent, and nowhere +/// else. +pub fn reject_reserved_name(name: &str) -> Result<()> { + if RESERVED_AGENT_NAMES.contains(&name) { + bail!( + "{name:?} is reserved: the swarm already routes messages to or from that name, so an \ + agent with it would have its own messages misread. Reserved: {}", + RESERVED_AGENT_NAMES.join(", ") + ); + } + Ok(()) +} + /// Same conservative charset `swarmctl::users::validate_username` already /// enforces — usernames are YAML map keys, log lines, and access-control /// subjects, so keeping them to plain ASCII means nothing downstream ever @@ -301,6 +339,50 @@ some_future_top_level_key: 7 ); } + /// Every reserved word is refused, named individually rather than looped + /// over the constant — a test that iterates the list it is testing passes + /// just as happily when the list is empty. + #[test] + fn every_reserved_name_is_refused() { + for name in [ + "operator", "manager", "root", "todo", "forge", "reminder", "system", "schedule", + ] { + assert!( + reject_reserved_name(name).is_err(), + "{name:?} must be reserved" + ); + } + } + + /// The control: an ordinary agent name is not caught. Without this the + /// arm above would pass a function that refused everything. + #[test] + fn an_ordinary_name_is_not_reserved() { + for name in ["atlas", "iris", "damocles", "operator-2", "todos"] { + reject_reserved_name(name) + .unwrap_or_else(|e| panic!("{name:?} should be allowed: {e}")); + } + } + + /// 🔑 The rule must NOT live in `validate_username`, because that runs + /// from `validate` over every user on every write — including humans this + /// bridge did not create. A store that already contains an operator named + /// `operator` has to stay writable, or a naming rule about *agents* would + /// silently become a rule about what the *file* may contain. + #[test] + fn a_reserved_name_already_in_the_store_does_not_block_writes() { + let mut store = UserStore::default(); + store + .users + .insert("operator".to_owned(), user("$argon2id$a")); + + render_yaml(&store).expect("an existing subject with a reserved name must stay writable"); + assert!( + reject_reserved_name("operator").is_err(), + "...while still being refused as a NEW agent name" + ); + } + #[test] fn usernames_outside_the_conservative_set_are_refused() { for bad in ["", "-leading", "has space", "quote\"d", "sla/sh"] {