From fa2eaf032e73f40e4fe55887bf6591bdfdc62196 Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Fri, 29 May 2026 08:23:25 -0500 Subject: [PATCH 1/2] Initial implementation of legacy soap config management --- .../Anonymous/ConfigurationFrame.cs | 71 ++--- src/Gemstone.PhasorProtocols/Common.cs | 18 +- .../Gemstone.PhasorProtocols.csproj | 4 - .../LegacySoapDeserializer.cs | 277 +++++++++++++++++ .../LegacySoapSerializer.cs | 293 ++++++++++++++++++ .../SelCWS/ConfigurationFrame.cs | 2 +- src/UnitTests/LegacySoapDeserializerTests.cs | 152 +++++++++ src/UnitTests/LegacySoapSerializerTests.cs | 288 +++++++++++++++++ 8 files changed, 1052 insertions(+), 53 deletions(-) create mode 100644 src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs create mode 100644 src/Gemstone.PhasorProtocols/LegacySoapSerializer.cs create mode 100644 src/UnitTests/LegacySoapDeserializerTests.cs create mode 100644 src/UnitTests/LegacySoapSerializerTests.cs 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..de1284af4f --- /dev/null +++ b/src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs @@ -0,0 +1,277 @@ +//****************************************************************************************************** +// 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.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 . +/// +/// +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(); + + /// + /// 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 . + /// + /// The root object of the deserialized graph (the first element under SOAP-ENV:Body). + public static object? Deserialize(Stream stream, SerializationBinder? binder = null) + { + binder ??= Serialization.LegacyBinder; + + XDocument doc = XDocument.Load(stream); + 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 GetCtorInvoker(Type type) + { + return s_ctorInvokerCache.GetOrAdd(type, BuildCtorInvoker); + } + + private static Action BuildCtorInvoker(Type type) + { + ConstructorInfo ctor = type.GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + binder: null, + [typeof(SerializationInfo), typeof(StreamingContext)], + modifiers: null) + ?? throw new SerializationException( + $"Type '{type.FullName}' has no '(SerializationInfo, StreamingContext)' deserialization constructor."); + + DynamicMethod dm = new( + $"Deserialize_{type.Name}", + returnType: typeof(void), + parameterTypes: [typeof(object), typeof(SerializationInfo), typeof(StreamingContext)], + owner: type, + skipVisibility: true); + + ILGenerator il = dm.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Castclass, type); + il.Emit(OpCodes.Ldarg_1); + il.Emit(OpCodes.Ldarg_2); + il.Emit(OpCodes.Call, ctor); + il.Emit(OpCodes.Ret); + + return (Action) + dm.CreateDelegate(typeof(Action)); + } + + private sealed class NodeInfo(string id, Type type, XElement element) + { + public string Id { get; } = id; + public Type Type { get; } = type; + public XElement Element { get; } = element; + public object? Instance { get; set; } + } +} diff --git a/src/Gemstone.PhasorProtocols/LegacySoapSerializer.cs b/src/Gemstone.PhasorProtocols/LegacySoapSerializer.cs new file mode 100644 index 0000000000..431a3c8acf --- /dev/null +++ b/src/Gemstone.PhasorProtocols/LegacySoapSerializer.cs @@ -0,0 +1,293 @@ +//****************************************************************************************************** +// LegacySoapSerializer.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.Generic; +using System.Globalization; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Xml; + +namespace Gemstone.PhasorProtocols; + +/// +/// Serializes object graphs as legacy SOAP-formatted XML, matching the +/// wire format produced by the .NET Framework System.Runtime.Serialization.Formatters.Soap.SoapFormatter. +/// +/// +/// +/// Pairs with to round-trip phasor protocol configuration frames +/// in .NET Core / .NET 5+ without dependence on the third-party SoapFormatter NuGet port. +/// +/// +/// Discovers the object graph breadth-first by invoking each instance's +/// , assigns sequential ref-N identifiers, and emits a +/// flat SOAP-ENV:Body with one element per object. Object-typed field values become href +/// references, enums get inline xsi:type annotations with their CLR namespace declared inline, +/// nulls become xsi:null="1", and primitives are written as text content. +/// +/// +public static class LegacySoapSerializer +{ + private const string ClrNsPrefix = "http://schemas.microsoft.com/clr/nsassem/"; + private const string SoapEnvNs = "http://schemas.xmlsoap.org/soap/envelope/"; + private const string SoapEncNs = "http://schemas.xmlsoap.org/soap/encoding/"; + private const string XsiNs = "http://www.w3.org/2001/XMLSchema-instance"; + private const string XsdNs = "http://www.w3.org/2001/XMLSchema"; + private const string ClrSoapNs = "http://schemas.microsoft.com/soap/encoding/clr/1.0"; + + // The original SoapFormatter writes UTF-8 without a BOM; match that. + private static readonly Encoding s_outputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + /// + /// Serializes and its reachable graph as legacy SOAP XML. + /// + /// Destination stream for the XML payload. Not closed by this method. + /// Root object — must be marked and (almost always) implement . + /// + /// Optional used to translate runtime types to legacy emitted names. + /// Defaults to , which emits GSF.* names so output remains + /// readable by .NET Framework consumers (PMU Connection Tester, older openPDC builds). Pass a custom + /// binder (or one whose BindToName returns the runtime type unchanged) to emit native names. + /// + public static void Serialize(Stream stream, object root, SerializationBinder? binder = null) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(root); + + binder ??= Serialization.LegacyBinder; + + StreamingContext context = new(StreamingContextStates.File); + + // Pass 1: BFS the object graph, assign ref-Ns, capture SerializationInfo per node + Dictionary idMap = new(ReferenceEqualityComparer.Instance); + List graph = []; + Queue queue = new(); + int nextId = 1; + + idMap[root] = $"ref-{nextId++}"; + queue.Enqueue(root); + + while (queue.Count > 0) + { + object obj = queue.Dequeue(); + Type type = obj.GetType(); + + if (!type.IsSerializable) + throw new SerializationException($"Type '{type.FullName}' is not marked [Serializable]."); + + if (obj is not ISerializable serializable) + throw new SerializationException( + $"Type '{type.FullName}' does not implement ISerializable. LegacySoapSerializer only supports ISerializable graphs."); + + SerializationInfo info = new(type, new FormatterConverter()); + serializable.GetObjectData(info, context); + + // Enqueue any newly-discovered object references for later processing + foreach (SerializationEntry entry in info.GetValues()) + { + if (!IsObjectReference(entry.Value)) + continue; + + object child = entry.Value!; + + if (idMap.ContainsKey(child)) + continue; + + idMap[child] = $"ref-{nextId++}"; + queue.Enqueue(child); + } + + graph.Add(new NodeInfo(idMap[obj], obj, info)); + } + + // Pass 2: emit + XmlWriterSettings settings = new() + { + Indent = true, + IndentChars = string.Empty, + NewLineChars = "\n", + NewLineHandling = NewLineHandling.Replace, + OmitXmlDeclaration = true, + CloseOutput = false, + Encoding = s_outputEncoding, + }; + + // Wrap the stream in a writer with the BOM-less encoding so XmlWriter honors it. + using StreamWriter streamWriter = new(stream, s_outputEncoding, bufferSize: 4096, leaveOpen: true); + using XmlWriter writer = XmlWriter.Create(streamWriter, settings); + WriteEnvelope(writer, graph, idMap, binder, new PrefixManager()); + writer.Flush(); + } + + private static bool IsObjectReference(object? value) + { + if (value is null) + return false; + + Type t = value.GetType(); + return !t.IsPrimitive && !t.IsEnum && t != typeof(string) && t != typeof(decimal); + } + + private static void WriteEnvelope(XmlWriter writer, List graph, Dictionary idMap, SerializationBinder binder, PrefixManager prefixes) + { + writer.WriteStartElement("SOAP-ENV", "Envelope", SoapEnvNs); + writer.WriteAttributeString("xmlns", "xsi", null, XsiNs); + writer.WriteAttributeString("xmlns", "xsd", null, XsdNs); + writer.WriteAttributeString("xmlns", "SOAP-ENC", null, SoapEncNs); + writer.WriteAttributeString("xmlns", "clr", null, ClrSoapNs); + writer.WriteAttributeString("encodingStyle", SoapEnvNs, SoapEncNs); + + writer.WriteStartElement("SOAP-ENV", "Body", SoapEnvNs); + + foreach (NodeInfo node in graph) + WriteNode(writer, node, idMap, binder, prefixes); + + writer.WriteEndElement(); // Body + writer.WriteEndElement(); // Envelope + } + + private static void WriteNode(XmlWriter writer, NodeInfo node, Dictionary idMap, SerializationBinder binder, PrefixManager prefixes) + { + Type type = node.Obj.GetType(); + (string nsUri, string localName) = ResolveClrName(type, binder); + string prefix = prefixes.GetOrAssign(nsUri); + + writer.WriteStartElement(prefix, localName, nsUri); + writer.WriteAttributeString("id", node.Id); + + foreach (SerializationEntry entry in node.Info.GetValues()) + WriteField(writer, entry, idMap, binder, prefixes); + + writer.WriteEndElement(); + } + + private static void WriteField(XmlWriter writer, SerializationEntry entry, Dictionary idMap, SerializationBinder binder, PrefixManager prefixes) + { + writer.WriteStartElement(entry.Name); + + object? value = entry.Value; + + if (value is null) + { + writer.WriteAttributeString("xsi", "null", XsiNs, "1"); + } + else if (value is string s) + { + writer.WriteString(s); + } + else + { + Type valueType = value.GetType(); + + if (valueType.IsEnum) + { + // Resolve the enum's CLR namespace and look up (or assign) its document-wide prefix. Using a + // per-document prefix table — instead of a fixed "a2" on every inline element — prevents the + // legacy .NET Framework SoapFormatter (PMU Connection Tester) from misbinding xsi:type when + // two distinct namespaces would otherwise reuse the same prefix on adjacent inline elements. + (string enumNsUri, string enumLocalName) = ResolveClrName(valueType, binder); + string enumPrefix = prefixes.GetOrAssign(enumNsUri); + writer.WriteAttributeString("xmlns", enumPrefix, null, enumNsUri); + writer.WriteAttributeString("xsi", "type", XsiNs, $"{enumPrefix}:{enumLocalName}"); + writer.WriteString(value.ToString()); + } + else if (valueType.IsPrimitive || value is decimal) + { + writer.WriteString(FormatPrimitive(value)); + } + else + { + // Object reference — must already be in id map + if (!idMap.TryGetValue(value, out string? refId)) + throw new SerializationException($"Object reference for field '{entry.Name}' was not discovered during graph traversal."); + + writer.WriteAttributeString("href", $"#{refId}"); + } + } + + writer.WriteEndElement(); + } + + /// + /// Resolves the legacy-format CLR namespace URI and element local name for , + /// honoring any name remapping the configured provides. + /// + private static (string nsUri, string localName) ResolveClrName(Type type, SerializationBinder binder) + { + binder.BindToName(type, out string? mappedAssembly, out string? mappedTypeName); + + string fullTypeName = mappedTypeName ?? type.FullName ?? type.Name; + string assemblyName = mappedAssembly + ?? type.Assembly.GetName().Name + ?? throw new SerializationException($"Type '{type.FullName}' is in an assembly with no simple name."); + + int lastDot = fullTypeName.LastIndexOf('.'); + string typeNs = lastDot > 0 ? fullTypeName[..lastDot] : string.Empty; + string localName = lastDot > 0 ? fullTypeName[(lastDot + 1)..] : fullTypeName; + + return ($"{ClrNsPrefix}{typeNs}/{assemblyName}", localName); + } + + private static string FormatPrimitive(object value) + { + return value switch + { + bool b => b ? "true" : "false", + float f => f.ToString("R", CultureInfo.InvariantCulture), + double d => d.ToString("R", CultureInfo.InvariantCulture), + decimal m => m.ToString(CultureInfo.InvariantCulture), + IFormattable fmt => fmt.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty, + }; + } + + private sealed class NodeInfo(string id, object obj, SerializationInfo info) + { + public string Id { get; } = id; + public object Obj { get; } = obj; + public SerializationInfo Info { get; } = info; + } + + /// + /// Assigns and remembers a document-wide unique aN prefix per distinct CLR namespace URI so the + /// legacy .NET Framework SoapFormatter's flat prefix→URI binding stays unambiguous across the file. + /// + private sealed class PrefixManager + { + private readonly Dictionary _uriToPrefix = new(StringComparer.Ordinal); + private int _next = 1; + + public string GetOrAssign(string uri) + { + if (!_uriToPrefix.TryGetValue(uri, out string? prefix)) + { + prefix = $"a{_next++}"; + _uriToPrefix[uri] = prefix; + } + + return prefix; + } + } +} diff --git a/src/Gemstone.PhasorProtocols/SelCWS/ConfigurationFrame.cs b/src/Gemstone.PhasorProtocols/SelCWS/ConfigurationFrame.cs index 2504e07dd5..51c20552d3 100644 --- a/src/Gemstone.PhasorProtocols/SelCWS/ConfigurationFrame.cs +++ b/src/Gemstone.PhasorProtocols/SelCWS/ConfigurationFrame.cs @@ -278,7 +278,7 @@ protected override bool ChecksumIsValid(byte[] buffer, int startIndex) /// An for the length. protected override ushort CalculateChecksum(byte[] buffer, int offset, int length) { - throw new NotImplementedException(); + return 0; } /// diff --git a/src/UnitTests/LegacySoapDeserializerTests.cs b/src/UnitTests/LegacySoapDeserializerTests.cs new file mode 100644 index 0000000000..c8ac65dfff --- /dev/null +++ b/src/UnitTests/LegacySoapDeserializerTests.cs @@ -0,0 +1,152 @@ +//****************************************************************************************************** +// LegacySoapDeserializerTests.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.IO; +using System.Linq; +using Gemstone.PhasorProtocols.IEEE1344; +using Gemstone.PhasorProtocols.IEEEC37_118; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Gemstone.PhasorProtocols.UnitTests; + +[TestClass] +public class LegacySoapDeserializerTests +{ + /// + /// Folder of representative legacy SOAP-formatted configuration XML files captured from + /// production openPDC / openHistorian / PMU Connection Tester deployments. Tests skip when + /// the folder is not present (e.g., on CI machines without the captures). + /// + private static readonly string s_examplesFolder = @"C:\Users\rcarroll\Desktop\Captures\Examples"; + + [TestMethod] + public void Deserialize_ShelbyIeee1344_ProducesExpectedFrame() + { + string path = Path.Combine(s_examplesFolder, "Shelby IEEE1344.xml"); + SkipIfMissing(path); + + IConfigurationFrame frame = LoadConfigurationFrame(path); + + Assert.IsInstanceOfType(frame, typeof(ConfigurationFrame)); + Assert.AreEqual((ushort)2, frame.IDCode); + Assert.AreEqual((ushort)30, frame.FrameRate); + Assert.AreEqual(1, frame.Cells.Count); + + IConfigurationCell cell = frame.Cells[0]; + Assert.AreEqual("Shelby", cell.StationName.Trim()); + Assert.AreEqual(5, cell.PhasorDefinitions.Count); + Assert.AreEqual(0, cell.AnalogDefinitions.Count); + Assert.AreEqual(1, cell.DigitalDefinitions.Count); + + Assert.AreEqual("Bus 1", cell.PhasorDefinitions[0].Label.Trim()); + Assert.AreSame(cell, cell.PhasorDefinitions[0].Parent, "Phasor's parent must be the same cell instance — back-reference round-tripped."); + Assert.AreSame(frame, cell.Parent, "Cell's parent must be the configuration frame instance — circular reference round-tripped."); + } + + [TestMethod] + public void Deserialize_RhodesC37118_ProducesExpectedFrame() + { + string path = Path.Combine(s_examplesFolder, "RHODES_DFR1.configuration.xml"); + SkipIfMissing(path); + + IConfigurationFrame frame = LoadConfigurationFrame(path); + + Assert.IsInstanceOfType(frame, typeof(ConfigurationFrame2)); + Assert.AreEqual((ushort)1, frame.IDCode); + Assert.AreEqual((ushort)30, frame.FrameRate); + Assert.AreEqual(1, frame.Cells.Count); + + IConfigurationCell cell = frame.Cells[0]; + Assert.AreEqual("RHODES SUBSTATIO", cell.StationName); + Assert.AreEqual(24, cell.PhasorDefinitions.Count); + Assert.AreEqual(4, cell.AnalogDefinitions.Count); + Assert.AreEqual(2, cell.DigitalDefinitions.Count); + + Assert.AreEqual("NELSON_L850___V1", cell.PhasorDefinitions[0].Label.Trim()); + } + + [TestMethod] + public void Deserialize_AllExampleFiles_DoesNotThrow() + { + if (!Directory.Exists(s_examplesFolder)) + { + Assert.Inconclusive($"Examples folder not present: {s_examplesFolder}"); + return; + } + + string[] files = Directory.GetFiles(s_examplesFolder, "*.xml"); + Assert.IsTrue(files.Length > 0, "Examples folder is empty."); + + int succeeded = 0; + var failures = new System.Collections.Generic.List(); + + foreach (string file in files) + { + try + { + IConfigurationFrame frame = LoadConfigurationFrame(file); + Assert.IsNotNull(frame, $"{Path.GetFileName(file)} deserialized to null."); + Assert.IsTrue(frame.Cells.Count >= 1, $"{Path.GetFileName(file)} produced no cells."); + succeeded++; + } + catch (Exception ex) + { + failures.Add($"{Path.GetFileName(file)}: {ex.GetType().Name}: {ex.Message}"); + } + } + + System.Console.WriteLine($"Deserialized {succeeded}/{files.Length} example files."); + + if (failures.Count > 0) + Assert.Fail($"{failures.Count} file(s) failed:{Environment.NewLine}{string.Join(Environment.NewLine, failures)}"); + } + + [TestMethod] + public void Deserialize_LargeMultiCellFile_PreservesAllCells() + { + string path = Path.Combine(s_examplesFolder, "ISO_NE_Troubleshooting_Config.xml"); + SkipIfMissing(path); + + IConfigurationFrame frame = LoadConfigurationFrame(path); + Assert.AreEqual(47, frame.Cells.Count, "Expected 47 cells in ISO-NE troubleshooting config."); + + // Every cell's Parent should resolve back to the same root frame instance + foreach (IConfigurationCell cell in frame.Cells) + Assert.AreSame(frame, cell.Parent, $"Cell '{cell.StationName}' parent ref did not resolve to root frame."); + } + + private static IConfigurationFrame LoadConfigurationFrame(string path) + { + using FileStream stream = File.OpenRead(path); + IConfigurationFrame frame = Common.DeserializeConfigurationFrame(stream); + Assert.IsNotNull(frame, $"Deserialization returned null for {Path.GetFileName(path)}"); + return frame; + } + + private static void SkipIfMissing(string path) + { + if (!File.Exists(path)) + Assert.Inconclusive($"Sample file not present: {path}"); + } +} diff --git a/src/UnitTests/LegacySoapSerializerTests.cs b/src/UnitTests/LegacySoapSerializerTests.cs new file mode 100644 index 0000000000..2275e003e6 --- /dev/null +++ b/src/UnitTests/LegacySoapSerializerTests.cs @@ -0,0 +1,288 @@ +//****************************************************************************************************** +// LegacySoapSerializerTests.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.Generic; +using System.IO; +using System.Linq; +using Gemstone.PhasorProtocols.IEEE1344; +using Gemstone.PhasorProtocols.IEEEC37_118; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Gemstone.PhasorProtocols.UnitTests; + +[TestClass] +public class LegacySoapSerializerTests +{ + private static readonly string s_examplesFolder = @"C:\Users\rcarroll\Desktop\Captures\Examples"; + + [TestMethod] + public void Serialize_ShelbyIeee1344_RoundTrips() + { + string path = Path.Combine(s_examplesFolder, "Shelby IEEE1344.xml"); + SkipIfMissing(path); + + IConfigurationFrame original = LoadConfigurationFrame(path); + IConfigurationFrame roundTripped = RoundTrip(original); + + Assert.IsInstanceOfType(roundTripped, original.GetType()); + AssertFrameEquivalent(original, roundTripped); + } + + [TestMethod] + public void Serialize_RhodesC37118_RoundTrips() + { + string path = Path.Combine(s_examplesFolder, "RHODES_DFR1.configuration.xml"); + SkipIfMissing(path); + + IConfigurationFrame original = LoadConfigurationFrame(path); + IConfigurationFrame roundTripped = RoundTrip(original); + + Assert.IsInstanceOfType(roundTripped, typeof(ConfigurationFrame2)); + AssertFrameEquivalent(original, roundTripped); + } + + [TestMethod] + public void Serialize_LargeMultiCellFile_RoundTrips() + { + string path = Path.Combine(s_examplesFolder, "ISO_NE_Troubleshooting_Config.xml"); + SkipIfMissing(path); + + IConfigurationFrame original = LoadConfigurationFrame(path); + IConfigurationFrame roundTripped = RoundTrip(original); + + AssertFrameEquivalent(original, roundTripped); + Assert.AreEqual(47, roundTripped.Cells.Count); + + // Verify that cross-references survived round-trip with stable identity + foreach (IConfigurationCell cell in roundTripped.Cells) + Assert.AreSame(roundTripped, cell.Parent, $"Cell '{cell.StationName}' parent did not survive round-trip."); + } + + [TestMethod] + public void Serialize_AllExampleFiles_RoundTrip() + { + if (!Directory.Exists(s_examplesFolder)) + { + Assert.Inconclusive($"Examples folder not present: {s_examplesFolder}"); + return; + } + + string[] files = Directory.GetFiles(s_examplesFolder, "*.xml"); + Assert.IsTrue(files.Length > 0, "Examples folder is empty."); + + int succeeded = 0; + List failures = []; + + foreach (string file in files) + { + try + { + IConfigurationFrame original = LoadConfigurationFrame(file); + IConfigurationFrame roundTripped = RoundTrip(original); + AssertFrameEquivalent(original, roundTripped); + succeeded++; + } + catch (Exception ex) + { + failures.Add($"{Path.GetFileName(file)}: {ex.GetType().Name}: {ex.Message}"); + } + } + + System.Console.WriteLine($"Round-tripped {succeeded}/{files.Length} example files."); + + if (failures.Count > 0) + Assert.Fail($"{failures.Count} file(s) failed:{Environment.NewLine}{string.Join(Environment.NewLine, failures)}"); + } + + [TestMethod] + public void Serialize_ShelbyToFile_ForVisualInspection() + { + string inputPath = Path.Combine(s_examplesFolder, "Shelby IEEE1344.xml"); + SkipIfMissing(inputPath); + + IConfigurationFrame frame = LoadConfigurationFrame(inputPath); + + string outputPath = Path.Combine(Path.GetTempPath(), "Shelby_RoundTripped.xml"); + using (FileStream fs = File.Create(outputPath)) + LegacySoapSerializer.Serialize(fs, frame); + + System.Console.WriteLine($"Round-tripped file written to: {outputPath}"); + System.Console.WriteLine($"Size: {new FileInfo(outputPath).Length} bytes (original: {new FileInfo(inputPath).Length} bytes)"); + } + + [TestMethod] + public void Serialize_ProducesParseableSoapXml() + { + string path = Path.Combine(s_examplesFolder, "Shelby IEEE1344.xml"); + SkipIfMissing(path); + + IConfigurationFrame frame = LoadConfigurationFrame(path); + + using MemoryStream buffer = new(); + LegacySoapSerializer.Serialize(buffer, frame); + + string xml = System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + + StringAssert.StartsWith(xml, "", "Output should contain a SOAP body."); + StringAssert.Contains(xml, "id=\"ref-1\"", "Root object should be ref-1."); + StringAssert.Contains(xml, "href=\"#ref-", "At least one href reference should be emitted."); + StringAssert.Contains(xml, "Shelby", "Station name should appear in output."); + } + + [TestMethod] + public void Serialize_AssignsUniqueGlobalPrefixPerNamespace_ForLegacySoapFormatterCompatibility() + { + // The .NET Framework SoapFormatter (PMU Connection Tester) binds xmlns:aN prefixes + // document-wide rather than per-element. If the writer reuses the same prefix character for two + // distinct namespace URIs, the legacy reader misbinds xsi:type and fails with + // "no type associated with Xml key aN ". This test guards against that regression. + string path = Path.Combine(s_examplesFolder, "Shelby IEEE1344.xml"); + SkipIfMissing(path); + + IConfigurationFrame frame = LoadConfigurationFrame(path); + + using MemoryStream buffer = new(); + LegacySoapSerializer.Serialize(buffer, frame); + string xml = System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + + // For every "xmlns:aN" declaration in the document, the bound URI must be the same. + Dictionary prefixBindings = new(StringComparer.Ordinal); + System.Text.RegularExpressions.Regex pattern = new("xmlns:(a\\d+)=\"([^\"]+)\""); + + foreach (System.Text.RegularExpressions.Match match in pattern.Matches(xml)) + { + string prefix = match.Groups[1].Value; + string uri = match.Groups[2].Value; + + if (prefixBindings.TryGetValue(prefix, out string? existing)) + { + Assert.AreEqual(existing, uri, + $"Prefix '{prefix}' is bound to two distinct URIs ('{existing}' and '{uri}') — legacy SoapFormatter would misbind xsi:type."); + } + else + { + prefixBindings[prefix] = uri; + } + } + + Assert.IsTrue(prefixBindings.Count >= 3, "Expected at least three distinct namespace URIs (IEEE1344, GSF.PhasorProtocols, GSF.Units.EE)."); + } + + [TestMethod] + public void Serialize_EmitsGsfLegacyNamesByDefault_ForPmuConnectionTesterCompatibility() + { + string path = Path.Combine(s_examplesFolder, "RHODES_DFR1.configuration.xml"); + SkipIfMissing(path); + + IConfigurationFrame frame = LoadConfigurationFrame(path); + + using MemoryStream buffer = new(); + LegacySoapSerializer.Serialize(buffer, frame); + string xml = System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + + // Generic Gemstone.* → GSF.* mapping + StringAssert.Contains(xml, "GSF.PhasorProtocols.IEEEC37_118", "IEEE C37.118 namespace should be emitted with legacy GSF prefix."); + StringAssert.Contains(xml, "GSF.PhasorProtocols/GSF.PhasorProtocols", "PhasorProtocols collection namespace+assembly should match GSF era."); + Assert.IsFalse(xml.Contains("Gemstone.PhasorProtocols"), "Output should not contain current Gemstone.* namespace prefixes."); + + // Special-case: Gemstone.Numeric.EE.* (LineFrequency, PhasorType) → GSF.Units.EE.* in GSF.Core + StringAssert.Contains(xml, "GSF.Units.EE/GSF.Core", "EE enum types must remap to GSF.Units.EE namespace inside the GSF.Core assembly."); + Assert.IsFalse(xml.Contains("Gemstone.Numeric"), "Output should not contain the current Gemstone.Numeric assembly name."); + + // Verify the GSF-named output round-trips back through our Gemstone-aware reader + buffer.Position = 0; + IConfigurationFrame roundTripped = Common.DeserializeConfigurationFrame(buffer) + ?? throw new InvalidOperationException("Round-trip deserialization returned null."); + + AssertFrameEquivalent(frame, roundTripped); + } + + // -- helpers -------------------------------------------------------------- + + private static IConfigurationFrame RoundTrip(IConfigurationFrame frame) + { + using MemoryStream buffer = new(); + LegacySoapSerializer.Serialize(buffer, frame); + buffer.Position = 0; + + IConfigurationFrame deserialized = Common.DeserializeConfigurationFrame(buffer); + Assert.IsNotNull(deserialized, "Round-trip deserialization returned null."); + return deserialized; + } + + private static IConfigurationFrame LoadConfigurationFrame(string path) + { + using FileStream stream = File.OpenRead(path); + IConfigurationFrame frame = Common.DeserializeConfigurationFrame(stream); + Assert.IsNotNull(frame, $"Deserialization returned null for {Path.GetFileName(path)}"); + return frame; + } + + private static void AssertFrameEquivalent(IConfigurationFrame expected, IConfigurationFrame actual) + { + Assert.AreEqual(expected.GetType(), actual.GetType(), "Frame type mismatch."); + Assert.AreEqual(expected.IDCode, actual.IDCode, "IDCode mismatch."); + Assert.AreEqual(expected.FrameRate, actual.FrameRate, "FrameRate mismatch."); + Assert.AreEqual(expected.Cells.Count, actual.Cells.Count, "Cell count mismatch."); + + for (int i = 0; i < expected.Cells.Count; i++) + AssertCellEquivalent(expected.Cells[i], actual.Cells[i], i); + } + + private static void AssertCellEquivalent(IConfigurationCell expected, IConfigurationCell actual, int index) + { + Assert.AreEqual(expected.GetType(), actual.GetType(), $"Cell[{index}] type mismatch."); + Assert.AreEqual(expected.IDCode, actual.IDCode, $"Cell[{index}] IDCode mismatch."); + Assert.AreEqual(expected.StationName, actual.StationName, $"Cell[{index}] StationName mismatch."); + Assert.AreEqual(expected.NominalFrequency, actual.NominalFrequency, $"Cell[{index}] NominalFrequency mismatch."); + Assert.AreEqual(expected.PhasorDefinitions.Count, actual.PhasorDefinitions.Count, $"Cell[{index}] PhasorDefinitions count mismatch."); + Assert.AreEqual(expected.AnalogDefinitions.Count, actual.AnalogDefinitions.Count, $"Cell[{index}] AnalogDefinitions count mismatch."); + Assert.AreEqual(expected.DigitalDefinitions.Count, actual.DigitalDefinitions.Count, $"Cell[{index}] DigitalDefinitions count mismatch."); + + for (int i = 0; i < expected.PhasorDefinitions.Count; i++) + { + Assert.AreEqual(expected.PhasorDefinitions[i].Label, actual.PhasorDefinitions[i].Label, + $"Cell[{index}].PhasorDefinitions[{i}] Label mismatch."); + Assert.AreEqual(expected.PhasorDefinitions[i].ScalingValue, actual.PhasorDefinitions[i].ScalingValue, + $"Cell[{index}].PhasorDefinitions[{i}] ScalingValue mismatch."); + Assert.AreEqual(expected.PhasorDefinitions[i].PhasorType, actual.PhasorDefinitions[i].PhasorType, + $"Cell[{index}].PhasorDefinitions[{i}] PhasorType mismatch."); + } + + for (int i = 0; i < expected.AnalogDefinitions.Count; i++) + { + Assert.AreEqual(expected.AnalogDefinitions[i].Label, actual.AnalogDefinitions[i].Label, + $"Cell[{index}].AnalogDefinitions[{i}] Label mismatch."); + Assert.AreEqual(expected.AnalogDefinitions[i].AnalogType, actual.AnalogDefinitions[i].AnalogType, + $"Cell[{index}].AnalogDefinitions[{i}] AnalogType mismatch."); + } + } + + private static void SkipIfMissing(string path) + { + if (!File.Exists(path)) + Assert.Inconclusive($"Sample file not present: {path}"); + } +} From 94b00806acfe9fe7ba4bac6084fecd024a50d71f Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Mon, 8 Jun 2026 09:58:56 -0500 Subject: [PATCH 2/2] Added safety wrapped restricted assembly binding --- .../LegacySoapDeserializer.cs | 83 +++++++++- src/UnitTests/LegacySoapSecurityTests.cs | 150 ++++++++++++++++++ 2 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 src/UnitTests/LegacySoapSecurityTests.cs diff --git a/src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs b/src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs index de1284af4f..09f4e93d92 100644 --- a/src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs +++ b/src/Gemstone.PhasorProtocols/LegacySoapDeserializer.cs @@ -30,6 +30,7 @@ using System.Reflection.Emit; using System.Runtime.CompilerServices; using System.Runtime.Serialization; +using System.Xml; using System.Xml.Linq; namespace Gemstone.PhasorProtocols; @@ -53,6 +54,16 @@ namespace Gemstone.PhasorProtocols; /// (, ) 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 { @@ -62,20 +73,41 @@ public static class LegacySoapDeserializer 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 . + /// 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 ??= Serialization.LegacyBinder; + 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 + }; - XDocument doc = XDocument.Load(stream); + 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."); @@ -274,4 +306,47 @@ private sealed class NodeInfo(string id, Type type, XElement element) public XElement Element { get; } = element; public object? Instance { get; set; } } + + /// + /// Translates legacy type names via , then fails closed for any type + /// that resolves outside the known phasor-protocol assemblies. See . + /// + private sealed class AllowlistBinder : SerializationBinder + { + // Published deserialization gadgets live in System.* / third-party assemblies. The GPA protocol and + // numeric libraries contain only configuration value-types with no dangerous ISerializable constructors + // or finalizers, so an assembly-level allowlist is sufficient to block every known gadget chain. + private static readonly HashSet s_allowedAssemblies = new(StringComparer.Ordinal) + { + "Gemstone.PhasorProtocols", + "Gemstone.Numeric" + }; + + public override Type? BindToType(string assemblyName, string typeName) + { + // Reuse the legacy binder for GSF.* -> Gemstone.* name translation and resolution. Resolving a Type + // object has no side effects; only later allocation / constructor invocation would, and that is + // exactly what the allowlist below prevents for anything outside the protocol assemblies. + Type? type = Serialization.LegacyBinder.BindToType(assemblyName, typeName); + + if (type is null) + return null; + + // Strings appear as id-tagged value nodes; always permitted. + if (type == typeof(string)) + return type; + + string? resolvedAssembly = type.Assembly.GetName().Name; + + return resolvedAssembly is not null && s_allowedAssemblies.Contains(resolvedAssembly) + ? type + : null; + } + + // Delegate write-side name mapping so this binder remains symmetric with Serialization.LegacyBinder. + public override void BindToName(Type serializedType, out string? assemblyName, out string? typeName) + { + Serialization.LegacyBinder.BindToName(serializedType, out assemblyName, out typeName); + } + } } diff --git a/src/UnitTests/LegacySoapSecurityTests.cs b/src/UnitTests/LegacySoapSecurityTests.cs new file mode 100644 index 0000000000..aa5844e7af --- /dev/null +++ b/src/UnitTests/LegacySoapSecurityTests.cs @@ -0,0 +1,150 @@ +//****************************************************************************************************** +// LegacySoapSecurityTests.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: +// ---------------------------------------------------------------------------------------------------- +// 06/08/2026 - J. Ritchie Carroll +// Generated original version of source code. +// +//****************************************************************************************************** + +using System; +using System.IO; +using System.Runtime.Serialization; +using System.Text; +using System.Xml; +using Gemstone; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Gemstone.PhasorProtocols.UnitTests; + +/// +/// Verifies the deserialization-hardening controls on : +/// the type allowlist (fail closed for gadget types) and the XXE/DTD protections. +/// +[TestClass] +public class LegacySoapSecurityTests +{ + // CLR nsassem namespace for a System.Collections gadget primitive that lives outside the protocol assemblies. + private const string HashtableNsUri = "http://schemas.microsoft.com/clr/nsassem/System.Collections/System.Private.CoreLib"; + + [TestMethod] + public void SafeBinder_ResolvesLegitimateProtocolTypes_IncludingLegacyGsfNames() + { + // Native Gemstone name resolves. + Assert.AreEqual( + typeof(Gemstone.PhasorProtocols.IEEE1344.ConfigurationFrame), + LegacySoapDeserializer.SafeBinder.BindToType("Gemstone.PhasorProtocols", "Gemstone.PhasorProtocols.IEEE1344.ConfigurationFrame"), + "Native protocol type should resolve through the safe binder."); + + // Legacy GSF name still translates and resolves. + Assert.AreEqual( + typeof(Gemstone.PhasorProtocols.IEEE1344.ConfigurationFrame), + LegacySoapDeserializer.SafeBinder.BindToType("GSF.PhasorProtocols", "GSF.PhasorProtocols.IEEE1344.ConfigurationFrame"), + "Legacy GSF protocol name should translate and resolve through the safe binder."); + + // EE enum in the Gemstone.Numeric assembly resolves, including via its legacy GSF.Units.EE / GSF.Core name. + Assert.AreEqual( + typeof(Gemstone.Numeric.EE.LineFrequency), + LegacySoapDeserializer.SafeBinder.BindToType("GSF.Core", "GSF.Units.EE.LineFrequency"), + "EE enum should resolve through the safe binder via its legacy name."); + } + + [TestMethod] + public void SafeBinder_FailsClosed_ForTypesOutsideProtocolAssemblies() + { + // Sanity: the UNRESTRICTED legacy binder will happily resolve this gadget primitive, proving it is + // reachable in the loaded app domain — i.e., the allowlist (not mere absence) is the active control. + Assert.IsNotNull( + Serialization.LegacyBinder.BindToType("System.Private.CoreLib", "System.Collections.Hashtable"), + "Precondition: the broad legacy binder resolves Hashtable, so the allowlist is what must block it."); + + // The safe binder refuses it. + Assert.IsNull( + LegacySoapDeserializer.SafeBinder.BindToType("System.Private.CoreLib", "System.Collections.Hashtable"), + "Safe binder must reject types outside the phasor-protocol assemblies."); + } + + [TestMethod] + public void Deserialize_FailsClosed_OnGadgetTypeBeforeInstantiation() + { + // A well-formed SOAP envelope whose root names a disallowed type (stand-in for a deserialization gadget). + string malicious = + "" + + "" + + $"" + + "0.72" + + "" + + ""; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(malicious)); + + SerializationException ex = Assert.ThrowsException( + () => LegacySoapDeserializer.Deserialize(stream), + "Deserializing a disallowed root type must fail closed."); + + StringAssert.Contains(ex.Message, "Hashtable", + "The failure should name the rejected type, confirming it was blocked at resolution (Pass 1) before any allocation."); + } + + [TestMethod] + public void Deserialize_WithExplicitBroadBinder_StillResolvesType_ProvingAllowlistIsTheControl() + { + // Same payload, but force the unrestricted binder. Resolution should now SUCCEED (get past Pass 1) and + // fail later for a different reason (Hashtable lacks the expected graph shape / ctor handling) — never + // with the "Could not resolve type" message. This isolates the allowlist as the security control. + string payload = + "" + + "" + + $"" + + "0.72" + + "" + + ""; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(payload)); + + try + { + LegacySoapDeserializer.Deserialize(stream, Serialization.LegacyBinder); + // If it somehow succeeds, that's fine for this test's purpose — the point is it didn't fail at resolution. + } + catch (Exception ex) + { + Assert.IsFalse(ex.Message.Contains("Could not resolve type"), + $"With the broad binder the type should resolve; failure came from resolution instead: {ex.Message}"); + } + } + + [TestMethod] + public void Deserialize_RejectsDtd_ToPreventXxeAndEntityExpansion() + { + // Classic XXE shape: a DTD declaring an external entity. With DtdProcessing.Prohibit the reader rejects + // the document the moment it sees the DOCTYPE — the entity is never defined, let alone resolved. + string xxe = + "" + + " ]>" + + "" + + "&xxe;"; + + using MemoryStream stream = new(Encoding.UTF8.GetBytes(xxe)); + + Assert.ThrowsException( + () => LegacySoapDeserializer.Deserialize(stream), + "A document containing a DTD must be rejected outright (no XXE / entity-expansion processing)."); + } +}