From 7ee6e776a368247065bd2cac7c15486b1b422154 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 12 Oct 2026 20:07:55 +0200 Subject: [PATCH] swarm: refuse swarm.hives keys that are not valid hive identifiers A hive key becomes a certificate subject and a directory name, but only the reserved-name and fragment guards looked at it, so `a/b` or `../x` evaluated and misbehaved later. Assert at eval time that every key is 1-63 characters of [a-z0-9-], the rule `Ident` applies in hive-types, and name the offending keys in the message. --- nix/host-modules/lib/name-guards.nix | 23 ++++++++++ nix/host-modules/swarm.nix | 4 ++ nix/module-eval/name-guards.nix | 65 +++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/lib/name-guards.nix b/nix/host-modules/lib/name-guards.nix index f9de705b..ff43d8e9 100644 --- a/nix/host-modules/lib/name-guards.nix +++ b/nix/host-modules/lib/name-guards.nix @@ -63,4 +63,27 @@ in problem = "has name(s) containing a reserved word"; forbiddenLabel = "Forbidden as substrings"; }; + + # Every name must be a valid hive `Ident` (hive-types/src/lib.rs): 1-63 + # characters of `[a-z0-9-]`, with no leading-character rule. A hive name is + # spliced into certificate subjects and filesystem paths, where `/` and `..` + # change meaning. + mustBeIdent = + { option, names }: + let + offenders = lib.filter (n: builtins.match "[a-z0-9-]+" n == null || lib.stringLength n > 63) names; + in + { + assertion = offenders == [ ]; + message = '' + ${option} has name(s) that are not valid identifiers: ${ + lib.concatMapStringsSep ", " (x: "'${x}'") offenders + } + Required: 1-63 characters, each one of [a-z0-9-] + + A hive name becomes a certificate subject and a directory name, so a + `/` or `..` in it names something else. `Ident` in + hive-types/src/lib.rs applies the same rule. Rename the hive. + ''; + }; } diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 9fee29c1..a92479d1 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -369,6 +369,10 @@ in designed for it. ''; } + (nameGuards.mustBeIdent { + option = "services.hyperhive.swarm.hives"; + names = hiveNames; + }) (nameGuards.mustNotEqual { option = "services.hyperhive.swarm.hives"; names = hiveNames; diff --git a/nix/module-eval/name-guards.nix b/nix/module-eval/name-guards.nix index ab6e57a7..3782110a 100644 --- a/nix/module-eval/name-guards.nix +++ b/nix/module-eval/name-guards.nix @@ -126,7 +126,38 @@ let swarm.hives."h1-agent".domain = "a.t.local"; }; - # Markers from `lib/name-guards.nix`'s two `problem` strings. Matching the + # The key characters are checked by the same module, so these run with the + # collector off too. `a/b` adds a certificate-subject component; `../x` + # leaves a directory named after the hive. + hiveNameWithSlash = hive { + deploy.swarm-otel.enable = false; + swarm.hives."a/b".domain = "a.t.local"; + }; + + hiveNameWithParentDir = hive { + deploy.swarm-otel.enable = false; + swarm.hives."../x".domain = "x.t.local"; + }; + + # Both sides of the length cap: 63 characters passes, 64 does not. + hiveNameAtLengthCap = hive { + deploy.swarm-otel.enable = false; + swarm.hives.${lib.concatStrings (lib.replicate 63 "a")}.domain = "l.t.local"; + }; + + hiveNameOverLengthCap = hive { + deploy.swarm-otel.enable = false; + swarm.hives.${lib.concatStrings (lib.replicate 64 "a")}.domain = "l.t.local"; + }; + + # Digits and hyphens in every position: the rule has no leading-character + # restriction. + hiveNameWithDigitsAndHyphens = hive { + deploy.swarm-otel.enable = false; + swarm.hives."0-h-9".domain = "h.t.local"; + }; + + # Markers from `lib/name-guards.nix`'s `problem` strings. Matching the # problem rather than the `why` prose keeps the messages rewordable. equalityGuardFired = h: lib.any (a: !a.assertion && lib.hasInfix "has reserved name(s)" a.message) h.assertions; @@ -136,7 +167,39 @@ let lib.any ( a: !a.assertion && lib.hasInfix "has name(s) containing a reserved word" a.message ) h.assertions; + + # True when the identifier guard refuses `key`. + identGuardRefuses = + h: key: + lib.any ( + a: + !a.assertion && lib.hasInfix "not valid identifiers" a.message && lib.hasInfix "'${key}'" a.message + ) h.assertions; + + identGuardFired = + h: lib.any (a: !a.assertion && lib.hasInfix "not valid identifiers" a.message) h.assertions; cases = [ + { + name = "a hive key containing a slash is refused, naming the key"; + ok = identGuardRefuses hiveNameWithSlash "a/b"; + } + { + name = "a hive key that climbs out of its directory is refused, naming the key"; + ok = identGuardRefuses hiveNameWithParentDir "../x"; + } + { + name = "a hive key over 63 characters is refused"; + ok = identGuardFired hiveNameOverLengthCap; + } + { + # Controls: without them an identifier guard that refuses everything + # passes the three cases above. + name = "63-character and digit-and-hyphen hive keys pass the identifier guard"; + ok = + !(identGuardFired hiveNameAtLengthCap) + && !(identGuardFired hiveNameWithDigitsAndHyphens) + && !(identGuardFired hiveNamesAllLegal); + } { # `ctl` is in no deny list — it is reserved *because it is the subject a # cert-auth role accepts*, which is a value an operator sets, so a