fix(dcl): ASCII-only connection actor names, guarded ActorOf, and gate rejection of colliding connection names (review 2026-10-21 lane A M2)

ConnectionActorNames.Sanitize (shared by the DCL manager and the site deploy
gate) keeps ASCII letters/digits and -_.*+:@&=,!~';() only. The manager fails a
connection whose sanitised name is invalid or already used (Error + site
event) instead of restarting; DeployCompileValidator rejects a config whose
connection names collide after sanitising.
This commit is contained in:
Joseph Doherty
2026-10-22 09:05:07 -04:00
parent 3c11510c52
commit 099b2221d6
6 changed files with 210 additions and 6 deletions
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
/// <summary>
/// Maps a data connection name to the Akka actor name of its <see cref="DataConnectionActor"/>
/// (review 2026-10-21, lane A M2). Shared by <see cref="DataConnectionManagerActor"/> (which
/// creates the actor) and the site deploy gate (which rejects a config whose connection names
/// would collide once sanitised), so both always agree on the name.
///
/// <para>
/// Every output is a legal Akka path element: ASCII letters and digits plus
/// <c>-_.*+:@&amp;=,!~';()</c> are kept, everything else — spaces, <c>/</c>, <c>#</c>, <c>$</c>
/// and every non-ASCII character — becomes <c>-</c>. The previous sanitiser kept
/// <see cref="char.IsLetterOrDigit(char)"/> (true for non-ASCII letters, which Akka refuses) and
/// <c>$</c> (refused as a leading character), so "Ofen Süd" or "$PLC" threw out of
/// <c>Context.ActorOf</c>. The mapping is NOT injective ("PLC A" and "PLC-A" both give
/// "PLC-A"); callers must treat a collision as an error for the later connection.
/// </para>
/// </summary>
public static class ConnectionActorNames
{
private const string KeptSymbols = "-_.*+:@&=,!~';()";
/// <summary>The actor name used for <paramref name="connectionName"/>.</summary>
/// <param name="connectionName">The data connection's configured name.</param>
/// <returns>A legal, non-empty Akka actor name.</returns>
public static string Sanitize(string connectionName)
{
if (string.IsNullOrEmpty(connectionName))
return "-";
var chars = new char[connectionName.Length];
for (var i = 0; i < chars.Length; i++)
{
var c = connectionName[i];
chars[i] = (c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9') ||
KeptSymbols.Contains(c)
? c
: '-';
}
return new string(chars);
}
}
@@ -92,11 +92,37 @@ public class DataConnectionManagerActor : ReceiveActor
command.FailoverRetryCount,
_siteEventLogger));
// Sanitize name for Akka actor path (replace spaces and invalid chars)
var actorName = new string(command.ConnectionName
.Select(c => char.IsLetterOrDigit(c) || "-_.*$+:@&=,!~';()".Contains(c) ? c : '-')
.ToArray());
var actorRef = Context.ActorOf(props, actorName);
// Sanitize name for Akka actor path — ASCII only, never a leading '$' (see
// ConnectionActorNames). Two connection names can still sanitise to the same actor name
// ("PLC A" / "PLC-A"), and ActorOf throws synchronously on that (or on any name Akka
// refuses): that must fail THIS connection only, never escape to the supervisor and
// restart the whole manager with every live connection (review 2026-10-21, lane A M2).
var actorName = ConnectionActorNames.Sanitize(command.ConnectionName);
IActorRef actorRef;
try
{
if (!Context.Child(actorName).IsNobody())
throw new InvalidActorNameException(
$"actor name [{actorName}] is already used by another connection");
actorRef = Context.ActorOf(props, actorName);
}
catch (InvalidActorNameException ex)
{
_log.Error(
"Connection {0} not created: its actor name '{1}' is invalid or already used by another " +
"connection ({2}). Rename the connection so its name differs in more than spaces/symbols.",
command.ConnectionName, actorName, ex.Message);
if (_siteEventLogger != null)
{
_ = _siteEventLogger.LogEventAsync(
"connection", "Error", null, command.ConnectionName,
$"Connection not created — runtime actor name '{actorName}' is invalid or already in use",
ex.Message);
}
adapter.DisposeAsync().AsTask().ContinueWith(_ => { });
return;
}
_connectionActors[command.ConnectionName] = actorRef;
_log.Info("Created DataConnectionActor for {0} (protocol={1})",
@@ -246,6 +246,28 @@ public sealed class DeployCompileValidator
foreach (var source in config.NativeAlarmSources)
AddActorNameError(errors, "Native alarm source", source.CanonicalName,
$"native-alarm-{source.CanonicalName}");
// Data connection names become DataConnectionActor names through the DCL's sanitiser,
// which is not injective: "PLC A" and "PLC-A" both map to "PLC-A", and the second
// connection could never be created (lane A M2). Reject such a pair here, by name.
if (config.Connections is { Count: > 0 } connections)
{
var byActorName = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var name in connections.Keys.Order(StringComparer.Ordinal))
{
var actorName = DataConnectionLayer.Actors.ConnectionActorNames.Sanitize(name);
if (byActorName.TryGetValue(actorName, out var other))
{
errors.Add($"{ActorNameErrorPrefix}Data connection names '{other}' and '{name}' " +
$"both map to the runtime actor name '{actorName}' — rename one so they " +
"differ in more than spaces or symbols.");
continue;
}
byActorName[actorName] = name;
AddActorNameError(errors, "Data connection", name, actorName);
}
}
}
private static void AddActorNameError(