From 0798c8e747142261ec602c6b608340911d1dbc69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 22 Jul 2026 13:13:41 +0200 Subject: [PATCH 1/2] test(common-tests): pin AgentUuid RFC 4122 contract on both resolution paths (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AgentUuidValidationTests + updates the existing AgentUuidConfigOverrideTests / AgentUuidLongitudinalInvariantsTests fixtures to pin the contract that every source of the Agent meta-device UUID (operator-supplied override AND persisted agent.information.json state) MUST parse as an RFC 4122 UUID before it reaches the wire. New coverage in AgentUuidValidationTests: * DeterministicAgentUuid.TryValidate low-level rejection (null, empty, whitespace, unparseable) and acceptance / normalization (canonical D-form unchanged; braced B, parenthesized P, bare-hex N normalized to D). * Malformed operator override on a fresh boot falls through to derived. * Malformed operator override with a valid persisted UUID preserves the persisted UUID. * Malformed persisted state with no override falls through to derived (Path 2 hardening — the missing symmetric guard). * Valid non-canonical operator override is normalized to hyphenated. * Persisted UUID is adopted on the second boot when no override is set. * First boot persists the derived UUID to agent.information.json so subsequent boots hit Path 2. Fixture updates: * AgentUuidConfigOverrideTests + AgentUuidLongitudinalInvariantsTests now route their boot simulation through AgentUuidResolver.Resolve (the shared production helper) instead of inline replay, so the tests cannot silently drift from StartAgent semantics. * Fixture literals converted from non-UUID strings (fixture-stable-uuid-A, from-config-uuid, etc.) to canonical RFC 4122 UUIDs so production validation would accept them (previously these fixtures passed only because their inline replay omitted TryValidate — fake-green). * Every fixture marked [NonParallelizable] and each SetUp sweeps orphan .bak.* / .valbak.* / .longinv.bak.* files from a prior crashed test run so successive TearDowns cannot restore stale state. This commit is compile-error RED — the tests reference AgentUuidResolver.Resolve and the AmE parameter name normalized which land in the following fix commit. The compile error proves the API absence per CONVENTIONS §1.0d-vicies-semel's behavioral-RED default. Claude-Session: https://claude.ai/code/session_015FLSuNX8hZPFiTGtE8GYqH --- .../Agents/AgentUuidConfigOverrideTests.cs | 89 ++-- .../AgentUuidLongitudinalInvariantsTests.cs | 111 ++--- .../Agents/AgentUuidValidationTests.cs | 379 ++++++++++++++++++ 3 files changed, 499 insertions(+), 80 deletions(-) create mode 100644 tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs index f46444b56..b8951378b 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidConfigOverrideTests.cs @@ -27,6 +27,7 @@ namespace MTConnect.Tests.Common.Agents /// Mirrors cppagent's AgentDeviceUUID configuration knob. /// [TestFixture] + [NonParallelizable] public class AgentUuidConfigOverrideTests { private string? _stateFilePath; @@ -37,6 +38,18 @@ public class AgentUuidConfigOverrideTests public void SetUp() { _stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename); + _backupStateFile = null; + + // Sweep orphan .bak.* files from a prior crashed test run so + // successive TearDowns cannot restore stale state. + var directory = Path.GetDirectoryName(_stateFilePath); + if (directory != null && Directory.Exists(directory)) + { + foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".bak.*")) + { + try { File.Delete(stale); } catch { /* best-effort */ } + } + } // Back up any pre-existing state file so we do not perturb other // tests or the developer environment. @@ -65,38 +78,30 @@ public void TearDown() /// /// Test (a) — pre-condition: no agent.information.json on disk. - /// Setting configuration.AgentUuid pins the agent UUID to that - /// exact value (overriding the Guid.NewGuid() in + /// Setting configuration.AgentUuid to a valid RFC 4122 UUID + /// pins the agent UUID to that exact value (overriding the + /// Guid.NewGuid() in /// 's parameterless ctor). /// [Test] public void AgentUuid_set_in_config_flows_through_to_Agent_uuid() { - const string PinnedUuid = "fixture-stable-uuid-001"; + const string PinnedUuid = "11111111-1111-4111-8111-111111111111"; var configuration = new AgentApplicationConfiguration { AgentUuid = PinnedUuid, }; - // Mirror the exact StartAgent threading slice under test: - // var agentInformation = MTConnectAgentInformation.Read(); - // if (agentInformation == null) agentInformation = new MTConnectAgentInformation(); - // if (!string.IsNullOrEmpty(configuration.AgentUuid)) - // agentInformation.Uuid = configuration.AgentUuid; - var agentInformation = MTConnectAgentInformation.Read(); - if (agentInformation == null) - { - agentInformation = new MTConnectAgentInformation(); - } - if (!string.IsNullOrEmpty(configuration.AgentUuid)) - { - agentInformation.Uuid = configuration.AgentUuid; - } + // Route through the production resolver so the test exercises the + // same code path StartAgent uses (see + // agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs + // RunAgent — resolves via AgentUuidResolver.Resolve). + var agentInformation = ResolveViaProduction(configuration); agentInformation.Save(); // The override must hold both in-memory and after the file - // round-trip that StartAgent performs at line 393. + // round-trip that StartAgent performs. Assert.That(agentInformation.Uuid, Is.EqualTo(PinnedUuid)); var reloaded = MTConnectAgentInformation.Read(); @@ -106,15 +111,16 @@ public void AgentUuid_set_in_config_flows_through_to_Agent_uuid() /// /// Test (b) — pre-condition: agent.information.json already - /// stores a different UUID. The config-level AgentUuid wins. + /// stores a different (valid) UUID. The config-level AgentUuid + /// wins. /// [Test] public void AgentUuid_set_in_config_takes_precedence_over_state_file() { - const string FromStateFileUuid = "from-state-file-uuid"; - const string FromConfigUuid = "from-config-uuid"; + const string FromStateFileUuid = "22222222-2222-4222-8222-222222222222"; + const string FromConfigUuid = "33333333-3333-4333-8333-333333333333"; - // Pre-write the state file with a stale UUID. + // Pre-write the state file with a stale (but valid) UUID. var preexisting = new MTConnectAgentInformation(FromStateFileUuid); preexisting.Save(); @@ -123,15 +129,12 @@ public void AgentUuid_set_in_config_takes_precedence_over_state_file() AgentUuid = FromConfigUuid, }; - var agentInformation = MTConnectAgentInformation.Read(); - Assert.That(agentInformation, Is.Not.Null); - Assert.That(agentInformation!.Uuid, Is.EqualTo(FromStateFileUuid), + var initial = MTConnectAgentInformation.Read(); + Assert.That(initial, Is.Not.Null); + Assert.That(initial!.Uuid, Is.EqualTo(FromStateFileUuid), "Pre-condition: the state file should be read first."); - if (!string.IsNullOrEmpty(configuration.AgentUuid)) - { - agentInformation.Uuid = configuration.AgentUuid; - } + var agentInformation = ResolveViaProduction(configuration); agentInformation.Save(); Assert.That(agentInformation.Uuid, Is.EqualTo(FromConfigUuid)); @@ -157,12 +160,14 @@ public void AgentUuid_set_in_config_takes_precedence_over_state_file() [Test] public void AgentUuid_is_exposed_on_interface_with_camelCase_wire_name() { + const string InterfaceProbe = "44444444-4444-4444-8444-444444444444"; + IAgentApplicationConfiguration configuration = new AgentApplicationConfiguration { - AgentUuid = "interface-surface-test", + AgentUuid = InterfaceProbe, }; - Assert.That(configuration.AgentUuid, Is.EqualTo("interface-surface-test")); + Assert.That(configuration.AgentUuid, Is.EqualTo(InterfaceProbe)); var property = typeof(AgentApplicationConfiguration).GetProperty( nameof(AgentApplicationConfiguration.AgentUuid), @@ -178,5 +183,27 @@ public void AgentUuid_is_exposed_on_interface_with_camelCase_wire_name() "AgentUuid must carry [JsonPropertyName(...)] to match the other config fields."); Assert.That(jsonNameAttribute!.Name, Is.EqualTo("agentUuid")); } + + /// + /// Replays the RunAgent UUID resolution slice via the shared + /// production helper so this fixture cannot silently drift from + /// StartAgent semantics. Returns the populated + /// ready for + /// . + /// + private static MTConnectAgentInformation ResolveViaProduction(AgentApplicationConfiguration configuration) + { + var existing = MTConnectAgentInformation.Read(); + var freshlyConstructed = existing == null; + var info = existing ?? new MTConnectAgentInformation(); + + info.Uuid = AgentUuidResolver.Resolve( + operatorSuppliedUuid: configuration.AgentUuid, + persistedUuid: freshlyConstructed ? null : info.Uuid, + agentName: configuration.ServiceName, + hostname: Environment.MachineName); + + return info; + } } } diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs index dfc745fdf..21369e56d 100644 --- a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidLongitudinalInvariantsTests.cs @@ -27,16 +27,28 @@ namespace MTConnect.Tests.Common.Agents /// this file pins the longitudinal invariant. /// [TestFixture] + [NonParallelizable] public class AgentUuidLongitudinalInvariantsTests { private string? _stateFilePath; private string? _backupStateFile; - /// Sets up the fixture before each test. + /// Sets up the fixture before all tests in the class. [OneTimeSetUp] public void OneTimeSetUp() { _stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename); + _backupStateFile = null; + + // Sweep orphan .longinv.bak.* files from a prior crashed test run. + var directory = Path.GetDirectoryName(_stateFilePath); + if (directory != null && Directory.Exists(directory)) + { + foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".longinv.bak.*")) + { + try { File.Delete(stale); } catch { /* best-effort */ } + } + } // Back up any pre-existing state file so we do not perturb other // tests or the developer environment. @@ -81,33 +93,30 @@ public void OneTimeTearDown() /// call to MTConnectAgentApplication.StartAgent, followed by the /// broker's post-device-add persist. /// - /// Production sources replayed (verify against live code if either drifts): - /// - /// - /// agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs - /// lines 351–404 — Read, AgentUuid override, InstanceId zeroing, Save. - /// - /// - /// libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs - /// lines 207–234 — broker ctor: _instanceId = instanceId > 0 ? instanceId : CreateInstanceId() - /// where CreateInstanceId() returns (ulong)(UnixDateTime.Now / 1000 / 10000) - /// (line 2351–2353), and lines 2321–2345 — UpdateAgentInformation timer-driven - /// persist after device-add. - /// - /// + /// The UUID resolution slice routes through + /// — the same call production + /// makes — so this fixture cannot silently drift from StartAgent + /// semantics (branch order, guard shape, validation). + /// + /// InstanceId handling remains inline because it has no shared + /// helper: it depends on configuration.Durable + + /// durableBufferLoadSucceeds and mirrors the broker ctor's + /// _instanceId = instanceId > 0 ? instanceId : CreateInstanceId() + /// contract (libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs). /// private static (string uuid, ulong instanceId) SimulateBoot( AgentApplicationConfiguration configuration, bool durableBufferLoadSucceeds) { - // Mirrors MTConnectAgentApplication.StartAgent lines 351–404: - var info = MTConnectAgentInformation.Read(); - if (info == null) info = new MTConnectAgentInformation(); + var existing = MTConnectAgentInformation.Read(); + var freshlyConstructed = existing == null; + var info = existing ?? new MTConnectAgentInformation(); - if (!string.IsNullOrEmpty(configuration.AgentUuid)) - { - info.Uuid = configuration.AgentUuid; - } + info.Uuid = AgentUuidResolver.Resolve( + operatorSuppliedUuid: configuration.AgentUuid, + persistedUuid: freshlyConstructed ? null : info.Uuid, + agentName: configuration.ServiceName, + hostname: Environment.MachineName); var initializeDataItems = !durableBufferLoadSucceeds; if (!configuration.Durable || initializeDataItems) @@ -117,17 +126,16 @@ private static (string uuid, ulong instanceId) SimulateBoot( info.Save(); - // Mirrors MTConnectAgent ctor (MTConnectAgent.cs lines 207–234): + // Mirrors MTConnectAgent ctor: // _instanceId = instanceId > 0 ? instanceId : CreateInstanceId(); - // CreateInstanceId() returns (ulong)(UnixDateTime.Now / 1000 / 10000) — Unix epoch seconds. + // CreateInstanceId() = (ulong)(UnixDateTime.Now / 1000 / 10000) — Unix epoch seconds. var brokerInstanceId = info.InstanceId > 0 ? info.InstanceId : (ulong)(UnixDateTime.Now / 1000 / 10000); - // Mirrors MTConnectAgent.UpdateAgentInformation (MTConnectAgent.cs lines 2321–2345): - // The broker writes its chosen _instanceId back to the file via a timer-driven - // persist once a device is added. Simulate that here so the next boot's Read() - // sees the broker's resolved InstanceId, not the zeroed value from the Save() above. + // Broker writes _instanceId back via UpdateAgentInformation once a + // device is added; simulate that so the next boot's Read() sees + // the broker's resolved InstanceId, not the zeroed value. info.InstanceId = brokerInstanceId; info.Save(); @@ -143,9 +151,11 @@ private static (string uuid, ulong instanceId) SimulateBoot( [Test] public void Uuid_pinned_via_config_survives_two_non_durable_boots() { + const string PinnedUuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + var configuration = new AgentApplicationConfiguration { - AgentUuid = "fixture-stable-uuid-A", + AgentUuid = PinnedUuid, Durable = false, }; @@ -156,9 +166,9 @@ public void Uuid_pinned_via_config_survives_two_non_durable_boots() var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: false); - Assert.That(uuid1, Is.EqualTo("fixture-stable-uuid-A"), + Assert.That(uuid1, Is.EqualTo(PinnedUuid), "Boot 1: config-level AgentUuid must be applied."); - Assert.That(uuid2, Is.EqualTo("fixture-stable-uuid-A"), + Assert.That(uuid2, Is.EqualTo(PinnedUuid), "Boot 2: config-level AgentUuid must survive a non-durable restart."); Assert.That(instanceId1, Is.Not.EqualTo(instanceId2), "Non-durable buffer means the InstanceId resets each boot — the UUIDs are equal but InstanceIds differ."); @@ -178,9 +188,11 @@ public void Uuid_pinned_via_config_survives_two_non_durable_boots() [Test] public void Uuid_pinned_via_config_survives_durable_boot_with_buffer_load_success() { + const string PinnedUuid = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + var configuration = new AgentApplicationConfiguration { - AgentUuid = "fixture-stable-uuid-B", + AgentUuid = PinnedUuid, Durable = true, }; @@ -192,28 +204,26 @@ public void Uuid_pinned_via_config_survives_durable_boot_with_buffer_load_succes // Boot 2: warm restart — durable buffer loaded successfully. var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: true); - Assert.That(uuid1, Is.EqualTo("fixture-stable-uuid-B"), + Assert.That(uuid1, Is.EqualTo(PinnedUuid), "Boot 1: config-level AgentUuid must be applied."); - Assert.That(uuid2, Is.EqualTo("fixture-stable-uuid-B"), + Assert.That(uuid2, Is.EqualTo(PinnedUuid), "Boot 2: config-level AgentUuid must survive a durable restart."); Assert.That(instanceId1, Is.EqualTo(instanceId2), "Durable buffer load success means InstanceId is preserved across boots (spec requirement)."); } /// - /// Documents the pre-fix bug from the consumer's perspective: - /// when AgentApplicationConfiguration.AgentUuid is null - /// and no state file persists across boots (e.g., an ephemeral container), - /// both UUID and InstanceId regenerate on every boot. - /// - /// This is not a regression check on the new feature — it is a - /// regression check on the bug itself still being a bug when the knob - /// is absent. Deleting the state file between boots simulates the - /// "no persistent storage" scenario that the new config knob lets - /// consumers escape. + /// Post-fix longitudinal invariant: when + /// AgentApplicationConfiguration.AgentUuid is + /// and no state file persists across boots (e.g. an ephemeral container), + /// the meta-device UUID is nevertheless stable across boots because + /// Path 3 derives it + /// deterministically from (agentName ?? hostname, hostname, port: 0). + /// The InstanceId still resets each boot because the durable + /// buffer did not load (spec-correct behaviour for Header.instanceId). /// [Test] - public void Uuid_not_pinned_and_no_state_file_regenerates_per_boot() + public void Uuid_not_pinned_and_no_state_file_is_stable_via_deterministic_derivation() { var configuration = new AgentApplicationConfiguration { @@ -234,11 +244,14 @@ public void Uuid_not_pinned_and_no_state_file_regenerates_per_boot() var (uuid2, instanceId2) = SimulateBoot(configuration, durableBufferLoadSucceeds: false); - Assert.That(uuid1, Is.Not.EqualTo(uuid2), - "No AgentUuid override + no state file = a fresh Guid is generated every boot. " + - "This is the pre-fix problem the new knob lets consumers avoid."); + Assert.That(uuid1, Is.EqualTo(uuid2), + "No override + no state file, but Path 3 derives deterministically " + + "from (agentName ?? hostname, hostname, port) — the meta-device UUID is stable " + + "across boots. This is the whole point of the AgentUuidResolver + " + + "DeterministicAgentUuid.Derive stack introduced by #168."); Assert.That(instanceId1, Is.Not.EqualTo(instanceId2), - "No state file = InstanceId is also regenerated each boot."); + "No state file = InstanceId is still regenerated each boot " + + "(Header.instanceId is per-boot by spec; separate concern from UUID stability)."); } } } diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs new file mode 100644 index 000000000..c404831df --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/AgentUuidValidationTests.cs @@ -0,0 +1,379 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.IO; +using MTConnect.Agents; +using MTConnect.Configurations; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Agents +{ + /// + /// Pins the contract that + /// gates every source of the Agent meta-device UUID (operator-supplied + /// override AND persisted agent.information.json state) so that + /// malformed values — anything that does not parse as an RFC 4122 UUID — + /// are rejected on the way in and the resolution falls through to the + /// next path (persisted → derived). + /// + /// + /// Silently forwarding a non-UUID string violates MTConnect Part 1, which + /// types the uuid attribute as the UUID DataType (RFC 4122 + /// enumerated string token). Any downstream XSD-validating consumer + /// (cppagent parity, MQTT/JSON-cppagent transport) rejects the resulting + /// wire content on typed enum/decimal DataItems. + /// + /// + /// + /// Fixture drives directly — the + /// same method MTConnectAgentApplication.StartAgent calls — so + /// production and tests cannot silently diverge on branch order, guard + /// semantics, or normalisation output. + /// + /// + [TestFixture] + [NonParallelizable] + public class AgentUuidValidationTests + { + private string _stateFilePath = null!; + private string? _backupStateFile; + + /// Sets up the fixture before each test. + [SetUp] + public void SetUp() + { + _stateFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, MTConnectAgentInformation.Filename); + _backupStateFile = null; + + // Sweep orphan .valbak.* files from a prior crashed test run so + // successive TearDowns cannot restore stale state. + var directory = Path.GetDirectoryName(_stateFilePath); + if (directory != null && Directory.Exists(directory)) + { + foreach (var stale in Directory.EnumerateFiles(directory, MTConnectAgentInformation.Filename + ".valbak.*")) + { + try { File.Delete(stale); } catch { /* best-effort — do not fail the test */ } + } + } + + // Back up any pre-existing state file so we do not perturb other + // tests or the developer environment. + if (File.Exists(_stateFilePath)) + { + _backupStateFile = _stateFilePath + ".valbak." + Guid.NewGuid().ToString("N"); + File.Move(_stateFilePath, _backupStateFile); + } + } + + /// Tears down the fixture after each test. + [TearDown] + public void TearDown() + { + // Remove any state file we left behind, then restore the backup. + if (File.Exists(_stateFilePath)) + { + File.Delete(_stateFilePath); + } + if (_backupStateFile != null && File.Exists(_backupStateFile)) + { + File.Move(_backupStateFile, _stateFilePath); + _backupStateFile = null; + } + } + + // ------------------------------------------------------------------ + // Unit tests — DeterministicAgentUuid.TryValidate low-level contract + // ------------------------------------------------------------------ + + /// + /// rejects + /// , empty, and whitespace-only inputs. + /// + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + [TestCase("\n")] + public void TryValidate_null_or_whitespace_returns_false(string input) + { + var ok = DeterministicAgentUuid.TryValidate(input, out var normalized); + + Assert.That(ok, Is.False); + Assert.That(normalized, Is.Null); + } + + /// + /// rejects strings + /// that do not parse as RFC 4122 UUIDs. + /// + [TestCase("not-a-uuid")] + [TestCase("fixture-stable-uuid-001")] + [TestCase("agent_1234567890abcdef")] + [TestCase("123")] + [TestCase("6ba7b810-9dad-11d1-80b4-00c04fd430c8-extra")] + public void TryValidate_unparseable_returns_false(string input) + { + var ok = DeterministicAgentUuid.TryValidate(input, out var normalized); + + Assert.That(ok, Is.False); + Assert.That(normalized, Is.Null); + } + + /// + /// accepts the + /// canonical hyphenated "D" form and returns it unchanged. + /// + [Test] + public void TryValidate_canonical_hyphenated_form_returns_true_unchanged() + { + const string Canonical = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + + var ok = DeterministicAgentUuid.TryValidate(Canonical, out var normalized); + + Assert.That(ok, Is.True); + Assert.That(normalized, Is.EqualTo(Canonical)); + } + + /// + /// normalizes the + /// braced "B", parenthesised "P", and bare-hex "N" forms to the + /// canonical hyphenated "D" form so the wire representation stays + /// stable regardless of the input format. + /// + [TestCase("{6ba7b810-9dad-11d1-80b4-00c04fd430c8}", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")] + [TestCase("(6ba7b810-9dad-11d1-80b4-00c04fd430c8)", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")] + [TestCase("6ba7b8109dad11d180b400c04fd430c8", "6ba7b810-9dad-11d1-80b4-00c04fd430c8")] + public void TryValidate_non_canonical_format_normalizes_to_hyphenated(string input, string expected) + { + var ok = DeterministicAgentUuid.TryValidate(input, out var normalized); + + Assert.That(ok, Is.True); + Assert.That(normalized, Is.EqualTo(expected)); + } + + // ------------------------------------------------------------------ + // Integration tests — AgentUuidResolver.Resolve three-path algorithm + // ------------------------------------------------------------------ + + /// + /// Boot-simulation regression — malformed + /// AgentApplicationConfiguration.AgentUuid on a fresh boot + /// (no agent.information.json) must fall through to the + /// derived UUID rather than being silently stored verbatim. + /// + [Test] + public void Malformed_AgentUuid_on_fresh_boot_falls_through_to_derived() + { + const string MalformedInput = "not-a-uuid"; + const string ServiceName = "test-agent-malformed-fresh"; + var hostname = Environment.MachineName; + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = MalformedInput, + ServiceName = ServiceName, + }; + + var resolved = SimulateFreshBoot(configuration, hostname); + + var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0); + Assert.That(resolved, Is.EqualTo(expectedDerived), + "Malformed operator override on a fresh boot must fall through to the derived UUID."); + Assert.That(resolved, Is.Not.EqualTo(MalformedInput), + "The malformed input must NOT be stored verbatim (Part-1 UuidType violation)."); + } + + /// + /// Boot-simulation regression — malformed + /// AgentApplicationConfiguration.AgentUuid when a valid + /// agent.information.json exists must preserve the persisted + /// UUID rather than being silently stored verbatim. + /// + [Test] + public void Malformed_AgentUuid_with_valid_persisted_state_preserves_persisted() + { + const string MalformedInput = "not-a-uuid"; + const string PersistedUuid = "cfbff0d1-9375-5685-968a-48ce8b50a653"; + + // Pre-write the state file with a valid UUID. + var preexisting = new MTConnectAgentInformation(PersistedUuid); + preexisting.Save(); + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = MalformedInput, + ServiceName = "test-agent-malformed-persisted", + }; + + var resolved = SimulateWarmBoot(configuration, Environment.MachineName); + + Assert.That(resolved, Is.EqualTo(PersistedUuid), + "Malformed operator override with valid persisted state must keep the persisted UUID."); + Assert.That(resolved, Is.Not.EqualTo(MalformedInput), + "The malformed input must NOT overwrite the persisted UUID."); + } + + /// + /// Path-2 hardening — malformed persisted state (e.g. a pre-hardening + /// agent version wrote a non-UUID string, or the file was hand-edited) + /// must ALSO fall through to the derived UUID rather than flowing on + /// to the wire. Prevents the exact XSD-validation failure the PR was + /// written to prevent, closed on the persisted-state axis too. + /// + [Test] + public void Malformed_persisted_state_with_no_override_falls_through_to_derived() + { + const string MalformedPersisted = "agent_1234567890abcdef"; + const string ServiceName = "test-agent-malformed-persisted-path2"; + var hostname = Environment.MachineName; + + var preexisting = new MTConnectAgentInformation(MalformedPersisted); + preexisting.Save(); + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = null, + ServiceName = ServiceName, + }; + + var resolved = SimulateWarmBoot(configuration, hostname); + + var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0); + Assert.That(resolved, Is.EqualTo(expectedDerived), + "Malformed persisted UUID with no override must fall through to derived — Path 2 must validate."); + Assert.That(resolved, Is.Not.EqualTo(MalformedPersisted), + "The malformed persisted value must NOT reach the wire."); + } + + /// + /// Boot-simulation regression — valid non-canonical + /// AgentApplicationConfiguration.AgentUuid (e.g. braced form) + /// is accepted and normalized to the canonical hyphenated form so the + /// wire representation stays stable across boots. + /// + [Test] + public void Valid_non_canonical_AgentUuid_is_normalized_to_hyphenated() + { + const string BracedInput = "{6ba7b810-9dad-11d1-80b4-00c04fd430c8}"; + const string ExpectedCanonical = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = BracedInput, + ServiceName = "test-agent-canonicalization", + }; + + var resolved = SimulateFreshBoot(configuration, Environment.MachineName); + + Assert.That(resolved, Is.EqualTo(ExpectedCanonical), + "Non-canonical valid UUID inputs must be normalized to the hyphenated D-form."); + } + + /// + /// Two-boot regression — first boot with no override and no state file + /// derives + persists a UUID; second boot with no override reads the + /// persisted UUID from agent.information.json and adopts it + /// verbatim (Path 2). Bit-identical across the two boots. + /// + [Test] + public void Persisted_UUID_is_adopted_on_second_boot_when_no_override() + { + const string ServiceName = "test-agent-persisted-second-boot"; + var hostname = Environment.MachineName; + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = null, + ServiceName = ServiceName, + }; + + // Boot 1 — no override, no state file → derive + persist. + var boot1Uuid = SimulateFreshBoot(configuration, hostname); + Assert.That(File.Exists(_stateFilePath), Is.True, + "Boot 1 must persist agent.information.json with the resolved UUID."); + var persistedAfterBoot1 = MTConnectAgentInformation.Read(); + Assert.That(persistedAfterBoot1, Is.Not.Null); + Assert.That(persistedAfterBoot1!.Uuid, Is.EqualTo(boot1Uuid), + "Boot 1's on-disk UUID must equal the resolved value."); + + // Boot 2 — no override, but persisted state exists → adopt persisted. + var boot2Uuid = SimulateWarmBoot(configuration, hostname); + Assert.That(boot2Uuid, Is.EqualTo(boot1Uuid), + "Boot 2 must adopt the persisted UUID bit-identically (Path 2)."); + } + + /// + /// First-boot persistence regression — no override, no state file → + /// Path 3 derives the UUID AND + /// writes it to agent.information.json so subsequent boots hit + /// Path 2. Guards against a regression where the resolve step returns + /// the derived value but the persist step is dropped. + /// + [Test] + public void First_boot_persists_derived_UUID_to_agent_information_json() + { + const string ServiceName = "test-agent-first-boot-persist"; + var hostname = Environment.MachineName; + + var configuration = new AgentApplicationConfiguration + { + AgentUuid = null, + ServiceName = ServiceName, + }; + + var resolved = SimulateFreshBoot(configuration, hostname); + + Assert.That(File.Exists(_stateFilePath), Is.True, + "agent.information.json must exist after the first boot."); + var persisted = MTConnectAgentInformation.Read(); + Assert.That(persisted, Is.Not.Null); + Assert.That(persisted!.Uuid, Is.EqualTo(resolved), + "Persisted UUID on disk must equal the resolved value."); + var expectedDerived = DeterministicAgentUuid.Derive(ServiceName, hostname, port: 0); + Assert.That(persisted.Uuid, Is.EqualTo(expectedDerived), + "Persisted UUID must equal the deterministic derivation for the given ServiceName."); + } + + // ------------------------------------------------------------------ + // Boot-simulation helpers — thin wrappers around AgentUuidResolver. + // ------------------------------------------------------------------ + + /// + /// Replays the fresh-boot UUID resolution slice of + /// MTConnectAgentApplication.StartAgent — no + /// agent.information.json on disk — via + /// (the exact call production + /// makes) followed by so + /// on-disk state assertions can verify persistence. + /// + private string SimulateFreshBoot( + AgentApplicationConfiguration configuration, + string hostname) + { + var existing = MTConnectAgentInformation.Read(); + var freshlyConstructed = existing == null; + var info = existing ?? new MTConnectAgentInformation(); + + info.Uuid = AgentUuidResolver.Resolve( + operatorSuppliedUuid: configuration.AgentUuid, + persistedUuid: freshlyConstructed ? null : info.Uuid, + agentName: configuration.ServiceName, + hostname: hostname); + + info.Save(); + return info.Uuid; + } + + /// + /// Replays the warm-boot UUID resolution slice — pre-existing + /// agent.information.json is read first — via + /// . Semantically identical to + /// ; two named helpers make the + /// per-test intent (fresh vs warm) unambiguous at the call site. + /// + private string SimulateWarmBoot( + AgentApplicationConfiguration configuration, + string hostname) => SimulateFreshBoot(configuration, hostname); + } +} From 30869b3b9113f876d5673be889aa61afcae53129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 22 Jul 2026 13:14:01 +0200 Subject: [PATCH 2/2] fix(agent): reject non-UUID AgentUuid on override AND persisted paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the Agent meta-device UUID resolution slice added by #168 so malformed input never reaches the wire, closing the gap on both the operator-supplied override AND the persisted agent.information.json paths — the previous single-path validation left Path 2 open. Change: * New AgentUuidResolver.Resolve (public static, MTConnect.NET-Common) implements the three-path algorithm — Path 1 validated override, Path 2 validated persisted state, Path 3 deterministic derivation — with a delegate-based warn hook so MTConnect.NET-Common takes no hard dependency on NLog. The delegate emits the raw AgentUuid value pre-sanitized (CRLF stripped, truncated to 64 chars) to guard against log-injection and paste-in-wrong-field secret leakage. * MTConnectAgentApplication.RunAgent routes through the shared resolver instead of hand-rolling the dual-if resolution. Collapses the two-branch shape that re-tested string.IsNullOrEmpty twice. * DeterministicAgentUuid.TryValidate parameter name normalised renamed to normalized per CONVENTIONS §1.0d-decies (AmE in committed code); XML-doc verbs (normalizes, parenthesized) updated to match. Positional callers and out-var callers are unaffected. The RED tests from the preceding commit turn GREEN because AgentUuidResolver.Resolve now exists and the AmE parameter name lands. The three test fixtures share the same resolver so drift between production and tests is impossible. Claude-Session: https://claude.ai/code/session_015FLSuNX8hZPFiTGtE8GYqH --- .../MTConnectAgentApplication.cs | 42 ++--- .../Agents/AgentUuidResolver.cs | 161 ++++++++++++++++++ .../Agents/DeterministicAgentUuid.cs | 44 +++++ 3 files changed, 221 insertions(+), 26 deletions(-) create mode 100644 libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs diff --git a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs index 523e4887d..1a8ff6257 100644 --- a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs +++ b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs @@ -433,32 +433,22 @@ public void StartAgent(IAgentApplicationConfiguration configuration, bool verbos var freshlyConstructed = (existingAgentInformation == null); var agentInformation = existingAgentInformation ?? new MTConnectAgentInformation(); - // Apply explicit AgentUuid config override (if set). This - // pins the Agent meta-device UUID across restarts without - // requiring agent.information.json to be present on disk, - // and takes precedence over any UUID previously stored in - // that file. See MTConnect v2.7 XSD UuidType ("for its - // entire life") vs Header.instanceId (per-boot). - if (!string.IsNullOrEmpty(configuration.AgentUuid)) - { - agentInformation.Uuid = configuration.AgentUuid; - } - - // When no operator config override and no persisted state file, - // derive a deterministic UUID v5 (RFC 4122 §4.3, DNS namespace, - // SHA-1) from ServiceName:port so the Agent meta-device satisfies - // UuidType's "for it's entire life" annotation across every restart - // in the ephemeral-container deployment path. Mirrors cppagent's - // name_generator prior art. Port is 0 (sentinel) because - // IAgentApplicationConfiguration does not surface a listener-port - // property; the seed is still unique per ServiceName. - if (freshlyConstructed && string.IsNullOrEmpty(configuration.AgentUuid)) - { - agentInformation.Uuid = DeterministicAgentUuid.Derive( - configuration.ServiceName, - System.Environment.MachineName, - port: 0); - } + // Resolve the Agent meta-device UUID via the shared three-path + // algorithm in AgentUuidResolver so this application and the + // test fixtures exercise the same code and cannot silently + // drift. Path 1 (validated operator override) wins, else Path 2 + // (validated persisted state), else Path 3 (deterministic + // UUID v5 derivation from ServiceName). Malformed input on + // either the override or the persisted path logs a warning + // and falls through — silently forwarding non-UUID content + // would break MTConnect Part 1's wire-XSD validation on every + // typed enum/decimal DataItem. + agentInformation.Uuid = AgentUuidResolver.Resolve( + operatorSuppliedUuid: configuration.AgentUuid, + persistedUuid: freshlyConstructed ? null : agentInformation.Uuid, + agentName: configuration.ServiceName, + hostname: System.Environment.MachineName, + warn: message => _applicationLogger?.Warn(message)); // Create Observation File Buffer if (configuration.Durable) diff --git a/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs b/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs new file mode 100644 index 000000000..2bb865452 --- /dev/null +++ b/libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs @@ -0,0 +1,161 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; + +namespace MTConnect.Agents +{ + /// + /// Resolves the Agent meta-device UUID from the three canonical sources — + /// operator-supplied config override, persisted agent.information.json + /// state, and a deterministic UUID v5 derivation — with RFC 4122 validation + /// applied uniformly on both the override and the persisted paths. + /// + /// + /// Shared between MTConnectAgentApplication.StartAgent and the test + /// fixtures so the boot-time resolution is exercised by the same code in + /// both places; the tests cannot silently drift from production semantics. + /// + /// + /// + /// Resolution order: + /// + /// + /// + /// Path 1 — operator-supplied override. If + /// parses as an RFC 4122 UUID + /// (via ), the canonical + /// hyphenated form wins. Malformed input logs a warning via + /// and falls through to Path 2 / Path 3. + /// + /// + /// Path 2 — persisted state. If + /// parses as an RFC 4122 UUID, the + /// canonical form wins. Malformed persisted state (e.g. a pre-hardening + /// agent version wrote a non-UUID string, or the file was hand-edited) + /// logs a warning and falls through to Path 3 so a spec-conformant UUID + /// always reaches the wire. + /// + /// + /// Path 3 — deterministic derivation. + /// over + /// (agentName ?? hostname, hostname, port: 0). Port is 0 + /// because IAgentApplicationConfiguration does not surface a + /// listener-port property; the seed is still unique per agent name. + /// + /// + /// + /// + /// Spec rationale — MTConnect Part 1 types the uuid attribute as the + /// UUID DataType (RFC 4122). Silently forwarding a non-UUID string + /// (from any source) breaks XSD validation on every typed enum/decimal + /// DataItem and diverges from the cppagent reference implementation, which + /// rejects malformed input at ingress. + /// + /// + public static class AgentUuidResolver + { + /// + /// Maximum length of an AgentUuid value echoed to the warning + /// log. Valid UUIDs are 32–38 characters; anything longer is almost + /// certainly a paste-in-wrong-field mistake (e.g. an API key) whose + /// full value should not persist in log archives. + /// + private const int LogValueMaxLength = 64; + + /// + /// Resolves the Agent meta-device UUID per the three-path algorithm. + /// + /// + /// Raw value from AgentApplicationConfiguration.AgentUuid; may + /// be , empty, or malformed. + /// + /// + /// Raw value from MTConnectAgentInformation.Read().Uuid, or + /// when no agent.information.json exists + /// (freshly constructed lifecycle). May itself be malformed if a prior + /// agent boot wrote non-UUID content. + /// + /// + /// The logical agent name (typically configuration.ServiceName). + /// Passed verbatim to , + /// which falls back to when this is + /// or empty. + /// + /// + /// The machine host name (typically + /// ). Used by + /// as both the fallback + /// seed component and the deterministic derivation input. + /// + /// + /// Optional delegate invoked with a human-readable message when Path 1 + /// or Path 2 rejects malformed input. Kept as a plain + /// so MTConnect.NET-Common does not + /// take a hard dependency on any logging framework; the caller adapts + /// it to NLog, Serilog, or Microsoft.Extensions.Logging. Raw + /// values echoed via this delegate are pre-sanitised (CRLF stripped, + /// truncated to characters) to guard + /// against log-injection and secret leakage. + /// + /// + /// The canonical hyphenated RFC 4122 UUID string that the agent must + /// adopt for its meta-device. + /// + public static string Resolve( + string operatorSuppliedUuid, + string persistedUuid, + string agentName, + string hostname, + Action warn = null) + { + // Path 1 — validated operator override wins. + if (DeterministicAgentUuid.TryValidate(operatorSuppliedUuid, out var normalizedOverride)) + { + return normalizedOverride; + } + + // Path 1 rejected but operator supplied something → warn. + if (!string.IsNullOrEmpty(operatorSuppliedUuid)) + { + var fallbackKind = DeterministicAgentUuid.TryValidate(persistedUuid, out _) + ? "persisted" + : "derived"; + warn?.Invoke(string.Format( + "AgentUuid override '{0}' is not a valid RFC 4122 UUID; falling back to {1} UUID.", + SanitiseForLog(operatorSuppliedUuid), + fallbackKind)); + } + + // Path 2 — validated persisted state wins over derivation. + if (DeterministicAgentUuid.TryValidate(persistedUuid, out var normalizedPersisted)) + { + return normalizedPersisted; + } + + // Path 2 rejected but persisted state carried something → warn. + if (!string.IsNullOrEmpty(persistedUuid)) + { + warn?.Invoke( + "Persisted AgentUuid in agent.information.json is not a valid RFC 4122 UUID; falling back to derived UUID."); + } + + // Path 3 — deterministic derivation. + return DeterministicAgentUuid.Derive(agentName, hostname, port: 0); + } + + /// + /// Strips CR/LF (log-injection guard) and truncates to + /// characters (secret-leakage guard) + /// before echoing an operator-supplied value to the warning log. + /// + private static string SanitiseForLog(string value) + { + if (string.IsNullOrEmpty(value)) return value; + var stripped = value.Replace("\r", string.Empty).Replace("\n", string.Empty); + return stripped.Length > LogValueMaxLength + ? stripped.Substring(0, LogValueMaxLength) + "…" + : stripped; + } + } +} diff --git a/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs b/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs index 518fd8c44..a2cac9c9b 100644 --- a/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs +++ b/libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs @@ -129,5 +129,49 @@ private static byte[] BigEndianToGuidBytes(byte[] beBytes) Buffer.BlockCopy(beBytes, 8, result, 8, 8); return result; } + + /// + /// Validates and normalizes an operator-supplied Agent UUID string. + /// + /// + /// Accepts any format that + /// recognizes — hyphenated "D" (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx), + /// braced "B", parenthesized "P", bare-hex "N", or hex-braced "X" — and + /// returns the canonical hyphenated "D" form so the wire representation + /// is stable regardless of input format. Rejects , + /// empty, whitespace-only, and unparseable inputs. + /// + /// Motivation: MTConnect Part 1 types the uuid attribute as the + /// UUID DataType (RFC 4122). Silently forwarding a non-UUID + /// string emits wire content that fails XSD validation on any typed + /// enum/decimal DataItem and breaks parity with the cppagent reference + /// implementation. Callers that supply malformed input should log a + /// warning and fall through to persisted or derived UUIDs — the + /// three-path resolution in . + /// + /// + /// + /// The raw operator-supplied value from + /// AgentApplicationConfiguration.AgentUuid; may be + /// or empty. + /// + /// + /// On success, the canonical hyphenated "D" form of the parsed UUID + /// (e.g. cfbff0d1-9375-5685-968a-48ce8b50a653); + /// on failure. + /// + /// + /// if parses as an + /// RFC 4122 UUID; for , + /// empty, whitespace-only, or unparseable inputs. + /// + public static bool TryValidate(string input, out string normalized) + { + normalized = null; + if (string.IsNullOrWhiteSpace(input)) return false; + if (!Guid.TryParse(input, out var parsed)) return false; + normalized = parsed.ToString(); + return true; + } } }