diff --git a/src/Gemstone.PhasorProtocols/Anonymous/ConfigurationFrame.cs b/src/Gemstone.PhasorProtocols/Anonymous/ConfigurationFrame.cs
index c673758b19..128b59f5ae 100644
--- a/src/Gemstone.PhasorProtocols/Anonymous/ConfigurationFrame.cs
+++ b/src/Gemstone.PhasorProtocols/Anonymous/ConfigurationFrame.cs
@@ -31,11 +31,12 @@
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
-using System.Runtime.Serialization.Formatters;
-using System.Runtime.Serialization.Formatters.Soap;
+using Gemstone.Configuration;
using Gemstone.IO;
using Gemstone.IO.Checksums.ChecksumExtensions;
using Gemstone.StringExtensions;
+using Gemstone.Threading.Collections;
+using Gemstone.Threading.SynchronizedOperations;
namespace Gemstone.PhasorProtocols.Anonymous
{
@@ -75,12 +76,12 @@ protected ConfigurationFrame(SerializationInfo info, StreamingContext context)
///
/// Gets reference to the for this .
///
- public new ConfigurationCellCollection Cells => base.Cells as ConfigurationCellCollection;
+ public new ConfigurationCellCollection Cells => (base.Cells as ConfigurationCellCollection)!;
///
/// Gets or sets name of this configuration frame as assigned or useful in an end-use context.
///
- public virtual string Name { get; set; }
+ public virtual string Name { get; set; } = string.Empty;
#endregion
@@ -104,18 +105,17 @@ protected override ushort CalculateChecksum(byte[] buffer, int offset, int lengt
#region [ Static ]
// Static Fields
- //private static readonly ProcessQueue, string>> s_configurationCacheQueue;
- private static string s_configurationCachePath;
+ private static readonly ProcessQueue, string>> s_configurationCacheQueue;
+ private static string? s_configurationCachePath;
private static int s_configurationBackups;
// Static Constructor
static ConfigurationFrame()
{
- // TODO: Move configuration caching to host application - not the best location to have this here
s_configurationBackups = -1;
- //s_configurationCacheQueue = ProcessQueue, string>>.CreateRealTimeQueue(CacheConfigurationFile);
- //s_configurationCacheQueue.SynchronizedOperationType = SynchronizedOperationType.LongBackground;
- //s_configurationCacheQueue.Start();
+ s_configurationCacheQueue = ProcessQueue, string>>.CreateRealTimeQueue(CacheConfigurationFile);
+ s_configurationCacheQueue.SynchronizedOperationType = SynchronizedOperationType.LongBackground;
+ s_configurationCacheQueue.Start();
}
// Static Properties
@@ -134,12 +134,12 @@ public static string ConfigurationCachePath
s_configurationCachePath = string.Format("{0}{1}ConfigurationCache{1}", FilePath.GetAbsolutePath(""), Path.DirectorySeparatorChar);
// Make sure configuration cache path setting exists within system settings section of config file
- //ConfigurationFile configFile = ConfigurationFile.Current;
- //CategorizedSettingsElementCollection systemSettings = configFile.Settings["systemSettings"];
- //systemSettings.Add("ConfigurationCachePath", s_configurationCachePath, "Defines the path used to cache serialized phasor protocol configurations");
+ dynamic settings = Settings.Default.System;
+
+ settings.ConfigurationCachePath = (s_configurationCachePath, "Defines the path used to cache serialized configurations");
// Retrieve configuration cache directory as defined in the config file
- //s_configurationCachePath = FilePath.AddPathSuffix(systemSettings["ConfigurationCachePath"].Value);
+ s_configurationCachePath = FilePath.AddPathSuffix(settings.ConfigurationCachePath);
// Make sure configuration cache directory exists
if (!Directory.Exists(s_configurationCachePath))
@@ -162,12 +162,12 @@ public static int ConfigurationBackups
const int DefaultConfigurationBackups = 5;
// Make sure configuration backups setting exists within system settings section of config file
- //ConfigurationFile configFile = ConfigurationFile.Current;
- //CategorizedSettingsElementCollection systemSettings = configFile.Settings["systemSettings"];
- //systemSettings.Add("ConfigurationBackups", DefaultConfigurationBackups, "Defines the total number of older backup configurations to maintain.");
+ dynamic settings = Settings.Default.System;
+
+ settings.ConfigurationBackups = (DefaultConfigurationBackups, "Defines the total number of older backup configurations to maintain");
// Retrieve configuration backups value as defined in the config file
- //s_configurationBackups = systemSettings["ConfigurationBackups"].ValueAs(DefaultConfigurationBackups);
+ s_configurationBackups = settings.ConfigurationBackups;
}
return s_configurationBackups;
@@ -185,16 +185,16 @@ public static int ConfigurationBackups
public static void Cache(IConfigurationFrame configurationFrame, Action exceptionHandler, string configurationName)
{
Tuple, string> cacheState = new(configurationFrame, exceptionHandler, configurationName);
- //s_configurationCacheQueue.Add(cacheState);
+ s_configurationCacheQueue.Add(cacheState);
}
// Cache configuration file
- private static void CacheConfigurationFile(Tuple, string> args)
+ private static void CacheConfigurationFile(Tuple, string>? args)
{
if (args is null)
return;
- FileStream configFile = null;
+ FileStream? configFile = null;
IConfigurationFrame configurationFrame = args.Item1;
Action exceptionHandler = args.Item2;
string configurationName = args.Item3;
@@ -246,15 +246,8 @@ private static void CacheConfigurationFile(Tuple
- /// Gets the file name with path of the specified .
+ /// Generates the full file name, including the path, for a configuration cache file.
///
- /// Name of the configuration to get file name for.
- /// File name with path of the specified .
- public static string GetConfigurationCacheFileName(string configurationName, string extension = "configuration.xml", string basePath = null)
+ /// The name of the configuration for which to generate the file name.
+ /// The file extension to use for the configuration cache file. Defaults to "configuration.xml".
+ /// The base path where the configuration cache file will be located. If not specified, a default path is used.
+ ///
+ /// A string representing the full file name, including the path, for the specified configuration cache file.
+ ///
+ ///
+ /// Invalid characters in the or are replaced
+ /// to ensure the resulting file name is valid.
+ ///
+ public static string GetConfigurationCacheFileName(string configurationName, string extension = "configuration.xml", string? basePath = null)
{
// Path traversal attacks are prevented by replacing invalid file name characters
- return $"{basePath ?? ConfigurationCachePath}{RemoveInvalidCharacters(configurationName)}.{RemoveInvalidCharacters(extension)}";
+ return $"{basePath ?? ConfigurationCachePath}{removeInvalidCharacters(configurationName)}.{removeInvalidCharacters(extension)}";
- static string RemoveInvalidCharacters(string name) =>
+ static string removeInvalidCharacters(string name) =>
name.ReplaceCharacters('_', c => Path.GetInvalidFileNameChars().Contains(c));
}
diff --git a/src/Gemstone.PhasorProtocols/Common.cs b/src/Gemstone.PhasorProtocols/Common.cs
index f49f61daec..098e81c475 100644
--- a/src/Gemstone.PhasorProtocols/Common.cs
+++ b/src/Gemstone.PhasorProtocols/Common.cs
@@ -31,8 +31,6 @@
using System;
using System.IO;
-using System.Runtime.Serialization.Formatters;
-using System.Runtime.Serialization.Formatters.Soap;
using Gemstone.IO.Parsing;
using Gemstone.StringExtensions;
@@ -111,19 +109,13 @@ public static void CopyImage(this ISupportBinaryImage channel, byte[] destinatio
///
/// that contains an XML serialized configuration frame.
/// Deserialized .
+ ///
+ /// Reads legacy SoapFormatter-produced XML via , which targets
+ /// the same wire format but works on .NET Core / .NET 5+ where SoapFormatter is unavailable.
+ ///
public static IConfigurationFrame? DeserializeConfigurationFrame(Stream configStream)
{
- SoapFormatter xmlSerializer = new()
- {
- AssemblyFormat = FormatterAssemblyStyle.Simple,
- TypeFormat = FormatterTypeStyle.TypesWhenNeeded,
- Binder = Serialization.LegacyBinder
- };
-
- //configFrame = xmlSerializer.Deserialize(new MemoryStream(Encoding.Default.GetBytes(xmlFile))) as IConfigurationFrame;
- IConfigurationFrame? configFrame = xmlSerializer.Deserialize(configStream) as IConfigurationFrame;
-
- return configFrame;
+ return LegacySoapDeserializer.Deserialize(configStream) as IConfigurationFrame;
}
///
diff --git a/src/Gemstone.PhasorProtocols/Gemstone.PhasorProtocols.csproj b/src/Gemstone.PhasorProtocols/Gemstone.PhasorProtocols.csproj
index 5d209fef25..7ac8616e06 100644
--- a/src/Gemstone.PhasorProtocols/Gemstone.PhasorProtocols.csproj
+++ b/src/Gemstone.PhasorProtocols/Gemstone.PhasorProtocols.csproj
@@ -94,10 +94,6 @@
-
-
-
-
diff --git a/src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs b/src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs
new file mode 100644
index 0000000000..09f4e93d92
--- /dev/null
+++ b/src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs
@@ -0,0 +1,352 @@
+//******************************************************************************************************
+// LegacySoapDeserializer.cs - Gbtc
+//
+// Copyright © 2026, Grid Protection Alliance. All Rights Reserved.
+//
+// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See
+// the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may
+// not use this file except in compliance with the License. You may obtain a copy of the License at:
+//
+// http://www.opensource.org/licenses/MIT
+//
+// Unless agreed to in writing, the subject software distributed under the License is distributed on an
+// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the
+// License for the specific language governing permissions and limitations.
+//
+// Code Modification History:
+// ----------------------------------------------------------------------------------------------------
+// 05/28/2026 - J. Ritchie Carroll
+// Generated original version of source code.
+//
+//******************************************************************************************************
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Reflection;
+using System.Reflection.Emit;
+using System.Runtime.CompilerServices;
+using System.Runtime.Serialization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace Gemstone.PhasorProtocols;
+
+///
+/// Deserializes legacy SOAP-formatted XML graphs previously produced by the .NET Framework
+/// System.Runtime.Serialization.Formatters.Soap.SoapFormatter.
+///
+///
+///
+/// .NET Core / .NET 5+ does not ship SoapFormatter, and existing third-party ports have
+/// limitations with interface-typed object references and circular graphs. This deserializer
+/// targets the specific dialect of SOAP emitted by .NET Framework's SoapFormatter against
+/// types — flat SOAP-ENV:Body, id/href reference
+/// resolution, and CLR nsassem namespace URIs.
+///
+///
+/// The deserializer pre-allocates every object node via
+/// before invoking any deserialization constructor, so circular references (parent/child) resolve to
+/// stable instance identities even though some constructors may not yet have run. Each
+/// (, ) constructor is invoked on the
+/// already-allocated instance via a compiled .
+///
+///
+/// Security: like any -based reader, materializing an attacker-named type and
+/// running its deserialization constructor is the mechanism behind the deserialization-gadget attacks that led
+/// to BinaryFormatter/SoapFormatter being deprecated. To contain this, the reader defaults to
+/// , which only resolves types from the phasor-protocol assemblies and fails closed for
+/// everything else before any object is allocated or any constructor runs. The XML is also parsed with
+/// DTD processing prohibited and external entity resolution disabled (no XXE/DTD bombs). Callers that genuinely
+/// need broader type resolution can pass explicitly, but should only do
+/// so for fully trusted input.
+///
+///
+public static class LegacySoapDeserializer
+{
+ private static readonly XNamespace s_soapEnvNs = "http://schemas.xmlsoap.org/soap/envelope/";
+ private static readonly XNamespace s_xsiNs = "http://www.w3.org/2001/XMLSchema-instance";
+ private const string ClrNsPrefix = "http://schemas.microsoft.com/clr/nsassem/";
+
+ private static readonly ConcurrentDictionary> s_ctorInvokerCache = new();
+
+ ///
+ /// Default used by . Applies the same legacy
+ /// GSF.* → Gemstone.* name translation as , then enforces an
+ /// allowlist so that only phasor-protocol configuration types can be materialized. Any type that resolves
+ /// outside the allowed assemblies is rejected (returns null), which causes deserialization to fail
+ /// closed before the type is allocated or its deserialization constructor runs. This neutralizes
+ /// deserialization-gadget attacks while preserving full read compatibility for legitimate configuration files.
+ ///
+ public static SerializationBinder SafeBinder { get; } = new AllowlistBinder();
+
+ ///
+ /// Deserializes a SOAP-formatted XML graph from .
+ ///
+ /// Source stream containing legacy SOAP XML.
+ ///
+ /// Optional used to translate serialized type names to current runtime
+ /// types. Defaults to , which fails closed for any type outside the phasor-protocol
+ /// assemblies. Pass for unrestricted resolution (trusted input only).
+ ///
+ /// The root object of the deserialized graph (the first element under SOAP-ENV:Body).
+ public static object? Deserialize(Stream stream, SerializationBinder? binder = null)
+ {
+ binder ??= SafeBinder;
+
+ // Harden the XML reader: prohibit DTDs and disable external entity resolution so that crafted input
+ // cannot trigger XXE or entity-expansion ("billion laughs") attacks. These files never contain a DTD.
+ XmlReaderSettings readerSettings = new()
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null,
+ CloseInput = false
+ };
+
+ using XmlReader reader = XmlReader.Create(stream, readerSettings);
+ XDocument doc = XDocument.Load(reader);
+ XElement body = doc.Root?.Element(s_soapEnvNs + "Body")
+ ?? throw new SerializationException("SOAP envelope missing Body element.");
+
+ // Pass 1: catalog every id-tagged element in the body, resolve its CLR type
+ Dictionary nodes = new(StringComparer.Ordinal);
+ string? rootId = null;
+
+ foreach (XElement element in body.Elements())
+ {
+ string? id = (string?)element.Attribute("id");
+
+ if (id is null)
+ continue;
+
+ Type type = ResolveElementType(element, binder);
+
+ nodes[id] = new NodeInfo(id, type, element);
+ rootId ??= id;
+ }
+
+ if (rootId is null)
+ throw new SerializationException("SOAP body contains no id-tagged elements.");
+
+ // Pass 2: pre-allocate every node so cross-references have stable identities
+ foreach (NodeInfo node in nodes.Values)
+ {
+ if (node.Type == typeof(string))
+ node.Instance = node.Element.Value;
+ else
+ node.Instance = RuntimeHelpers.GetUninitializedObject(node.Type);
+ }
+
+ // Pass 3: build SerializationInfo for each non-string node and invoke its deserialization ctor
+ StreamingContext context = new(StreamingContextStates.File);
+
+ foreach (NodeInfo node in nodes.Values)
+ {
+ if (node.Type == typeof(string))
+ continue;
+
+ SerializationInfo info = BuildSerializationInfo(node, nodes, binder);
+ GetCtorInvoker(node.Type)(node.Instance!, info, context);
+ }
+
+ return nodes[rootId].Instance;
+ }
+
+ private static Type ResolveElementType(XElement element, SerializationBinder binder)
+ {
+ (string typeName, string assemblyName) = ParseClrNamespace(element.Name.NamespaceName, element.Name.LocalName);
+
+ return binder.BindToType(assemblyName, typeName)
+ ?? throw new SerializationException(
+ $"Could not resolve type '{typeName}' in assembly '{assemblyName}' for element '{element.Name.LocalName}'.");
+ }
+
+ private static (string typeName, string assemblyName) ParseClrNamespace(string namespaceUri, string elementLocalName)
+ {
+ if (!namespaceUri.StartsWith(ClrNsPrefix, StringComparison.Ordinal))
+ throw new SerializationException($"Element '{elementLocalName}' has non-CLR namespace URI: '{namespaceUri}'.");
+
+ string remainder = namespaceUri[ClrNsPrefix.Length..];
+ int lastSlash = remainder.LastIndexOf('/');
+
+ if (lastSlash < 0)
+ throw new SerializationException($"Malformed CLR namespace URI: '{namespaceUri}'.");
+
+ string typeNamespace = remainder[..lastSlash];
+ string assemblyName = remainder[(lastSlash + 1)..];
+
+ return ($"{typeNamespace}.{elementLocalName}", assemblyName);
+ }
+
+ private static SerializationInfo BuildSerializationInfo(NodeInfo node, Dictionary nodes, SerializationBinder binder)
+ {
+ SerializationInfo info = new(node.Type, new FormatterConverter());
+
+ foreach (XElement field in node.Element.Elements())
+ {
+ (object? value, Type valueType) = ResolveFieldValue(field, nodes, binder);
+ info.AddValue(field.Name.LocalName, value, valueType);
+ }
+
+ return info;
+ }
+
+ private static (object? value, Type valueType) ResolveFieldValue(XElement field, Dictionary nodes, SerializationBinder binder)
+ {
+ // xsi:null="1" marks the field as null
+ XAttribute? nullAttr = field.Attribute(s_xsiNs + "null");
+
+ if (nullAttr is not null && nullAttr.Value == "1")
+ return (null, typeof(object));
+
+ // href="#ref-X" — object reference to a pre-allocated instance
+ XAttribute? hrefAttr = field.Attribute("href");
+
+ if (hrefAttr is not null)
+ {
+ string refId = hrefAttr.Value.StartsWith('#') ? hrefAttr.Value[1..] : hrefAttr.Value;
+
+ if (!nodes.TryGetValue(refId, out NodeInfo? refNode))
+ throw new SerializationException($"Unresolved object reference '{hrefAttr.Value}'.");
+
+ return (refNode.Instance, refNode.Type);
+ }
+
+ // xsi:type="prefix:LocalName" — typed inline value, usually a (flags) enum
+ XAttribute? typeAttr = field.Attribute(s_xsiNs + "type");
+
+ if (typeAttr is not null && TryResolveXsiType(field, typeAttr.Value, binder, out Type? resolvedType))
+ {
+ string text = field.Value;
+
+ if (resolvedType.IsEnum)
+ return (Enum.Parse(resolvedType, text), resolvedType);
+
+ // Inline primitive value typed via xsi:type (rare for these graphs but cheap to support)
+ if (resolvedType.IsPrimitive || resolvedType == typeof(string) || resolvedType == typeof(decimal))
+ return (Convert.ChangeType(text, resolvedType, CultureInfo.InvariantCulture), resolvedType);
+
+ // Fall through — let it be treated as raw text
+ }
+
+ // Plain text content (primitives store as string; FormatterConverter handles conversion to the
+ // requested numeric/boolean type when the deserialization ctor calls GetInt32/GetUInt16/etc.)
+ return (field.Value, typeof(string));
+ }
+
+ private static bool TryResolveXsiType(XElement field, string qualifiedTypeName, SerializationBinder binder, out Type? resolvedType)
+ {
+ resolvedType = null;
+
+ int colonIdx = qualifiedTypeName.IndexOf(':');
+
+ if (colonIdx <= 0)
+ return false;
+
+ string prefix = qualifiedTypeName[..colonIdx];
+ string localName = qualifiedTypeName[(colonIdx + 1)..];
+ XNamespace typeNs = field.GetNamespaceOfPrefix(prefix);
+
+ if (typeNs is null || !typeNs.NamespaceName.StartsWith(ClrNsPrefix, StringComparison.Ordinal))
+ return false;
+
+ try
+ {
+ (string typeName, string assemblyName) = ParseClrNamespace(typeNs.NamespaceName, localName);
+ resolvedType = binder.BindToType(assemblyName, typeName);
+ return resolvedType is not null;
+ }
+ catch (SerializationException)
+ {
+ return false;
+ }
+ }
+
+ private static Action