Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
161 changes: 161 additions & 0 deletions libraries/MTConnect.NET-Common/Agents/AgentUuidResolver.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Resolves the Agent meta-device UUID from the three canonical sources —
/// operator-supplied config override, persisted <c>agent.information.json</c>
/// state, and a deterministic UUID v5 derivation — with RFC 4122 validation
/// applied uniformly on both the override and the persisted paths.
///
/// <para>
/// Shared between <c>MTConnectAgentApplication.StartAgent</c> 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.
/// </para>
///
/// <para>
/// Resolution order:
/// </para>
/// <list type="number">
/// <item>
/// <b>Path 1 — operator-supplied override.</b> If
/// <paramref name="operatorSuppliedUuid"/> parses as an RFC 4122 UUID
/// (via <see cref="DeterministicAgentUuid.TryValidate"/>), the canonical
/// hyphenated form wins. Malformed input logs a warning via
/// <paramref name="warn"/> and falls through to Path 2 / Path 3.
/// </item>
/// <item>
/// <b>Path 2 — persisted state.</b> If
/// <paramref name="persistedUuid"/> 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.
/// </item>
/// <item>
/// <b>Path 3 — deterministic derivation.</b>
/// <see cref="DeterministicAgentUuid.Derive"/> over
/// <c>(agentName ?? hostname, hostname, port: 0)</c>. Port is <c>0</c>
/// because <c>IAgentApplicationConfiguration</c> does not surface a
/// listener-port property; the seed is still unique per agent name.
/// </item>
/// </list>
///
/// <para>
/// Spec rationale — MTConnect Part 1 types the <c>uuid</c> attribute as the
/// <c>UUID</c> 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.
/// </para>
/// </summary>
public static class AgentUuidResolver
{
/// <summary>
/// Maximum length of an <c>AgentUuid</c> 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.
/// </summary>
private const int LogValueMaxLength = 64;

/// <summary>
/// Resolves the Agent meta-device UUID per the three-path algorithm.
/// </summary>
/// <param name="operatorSuppliedUuid">
/// Raw value from <c>AgentApplicationConfiguration.AgentUuid</c>; may
/// be <see langword="null"/>, empty, or malformed.
/// </param>
/// <param name="persistedUuid">
/// Raw value from <c>MTConnectAgentInformation.Read().Uuid</c>, or
/// <see langword="null"/> when no <c>agent.information.json</c> exists
/// (freshly constructed lifecycle). May itself be malformed if a prior
/// agent boot wrote non-UUID content.
/// </param>
/// <param name="agentName">
/// The logical agent name (typically <c>configuration.ServiceName</c>).
/// Passed verbatim to <see cref="DeterministicAgentUuid.Derive"/>,
/// which falls back to <paramref name="hostname"/> when this is
/// <see langword="null"/> or empty.
/// </param>
/// <param name="hostname">
/// The machine host name (typically
/// <see cref="Environment.MachineName"/>). Used by
/// <see cref="DeterministicAgentUuid.Derive"/> as both the fallback
/// seed component and the deterministic derivation input.
/// </param>
/// <param name="warn">
/// Optional delegate invoked with a human-readable message when Path 1
/// or Path 2 rejects malformed input. Kept as a plain
/// <see cref="Action{T}"/> so <c>MTConnect.NET-Common</c> does not
/// take a hard dependency on any logging framework; the caller adapts
/// it to NLog, Serilog, or <c>Microsoft.Extensions.Logging</c>. Raw
/// values echoed via this delegate are pre-sanitised (CRLF stripped,
/// truncated to <see cref="LogValueMaxLength"/> characters) to guard
/// against log-injection and secret leakage.
/// </param>
/// <returns>
/// The canonical hyphenated RFC 4122 UUID string that the agent must
/// adopt for its meta-device.
/// </returns>
public static string Resolve(
string operatorSuppliedUuid,
string persistedUuid,
string agentName,
string hostname,
Action<string> 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);
}

/// <summary>
/// Strips CR/LF (log-injection guard) and truncates to
/// <see cref="LogValueMaxLength"/> characters (secret-leakage guard)
/// before echoing an operator-supplied value to the warning log.
/// </summary>
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;
}
}
}
44 changes: 44 additions & 0 deletions libraries/MTConnect.NET-Common/Agents/DeterministicAgentUuid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,49 @@ private static byte[] BigEndianToGuidBytes(byte[] beBytes)
Buffer.BlockCopy(beBytes, 8, result, 8, 8);
return result;
}

/// <summary>
/// Validates and normalizes an operator-supplied Agent UUID string.
/// </summary>
/// <remarks>
/// Accepts any format that <see cref="Guid.TryParse(string, out Guid)"/>
/// recognizes — hyphenated "D" (<c>xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</c>),
/// 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 <see langword="null"/>,
/// empty, whitespace-only, and unparseable inputs.
/// <para>
/// Motivation: MTConnect Part 1 types the <c>uuid</c> attribute as the
/// <c>UUID</c> 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 <see cref="AgentUuidResolver.Resolve"/>.
/// </para>
/// </remarks>
/// <param name="input">
/// The raw operator-supplied value from
/// <c>AgentApplicationConfiguration.AgentUuid</c>; may be
/// <see langword="null"/> or empty.
/// </param>
/// <param name="normalized">
/// On success, the canonical hyphenated "D" form of the parsed UUID
/// (e.g. <c>cfbff0d1-9375-5685-968a-48ce8b50a653</c>); <see langword="null"/>
/// on failure.
/// </param>
/// <returns>
/// <see langword="true"/> if <paramref name="input"/> parses as an
/// RFC 4122 UUID; <see langword="false"/> for <see langword="null"/>,
/// empty, whitespace-only, or unparseable inputs.
/// </returns>
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;
}
}
}
Loading