From ef674c45dbea03c180f56945ac6512cd54a53134 Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sun, 2 Aug 2026 16:49:47 -0700 Subject: [PATCH 1/3] Extract LIKE pattern matching into LikeMatcher The events Like operator owned the only implementation of the Part 4 LIKE grammar, and alias name search needs the same matching. Move parsing, matching, and the LRU pattern cache into a reusable LikeMatcher; the operator delegates with no behavior change. The new compile() entry point lets callers that evaluate one pattern against many values parse it once. Co-Authored-By: Claude Fable 5 --- .../sdk/server/events/operators/Like.java | 210 +----------- .../opcua/sdk/server/util/LikeMatcher.java | 302 ++++++++++++++++++ .../sdk/server/util/LikeMatcherTest.java | 187 +++++++++++ 3 files changed, 493 insertions(+), 206 deletions(-) create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/util/LikeMatcher.java create mode 100644 opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/util/LikeMatcherTest.java diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/events/operators/Like.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/events/operators/Like.java index c35bc1fc63..fc8f6e2d53 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/events/operators/Like.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/events/operators/Like.java @@ -10,16 +10,12 @@ package org.eclipse.milo.opcua.sdk.server.events.operators; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; import org.eclipse.milo.opcua.sdk.server.events.FilterContext; import org.eclipse.milo.opcua.sdk.server.events.OperatorContext; import org.eclipse.milo.opcua.sdk.server.events.ValidationException; import org.eclipse.milo.opcua.sdk.server.events.conversions.ImplicitConversions; import org.eclipse.milo.opcua.sdk.server.model.objects.BaseEventTypeNode; +import org.eclipse.milo.opcua.sdk.server.util.LikeMatcher; import org.eclipse.milo.opcua.stack.core.OpcUaDataType; import org.eclipse.milo.opcua.stack.core.UaException; import org.eclipse.milo.opcua.stack.core.types.structured.FilterOperand; @@ -27,32 +23,7 @@ public class Like implements Operator { - /** - * Upper bound on the number of distinct parsed patterns retained, so a client that sends many - * distinct patterns cannot grow the cache without bound. - */ - private static final int MAX_CACHED_PATTERNS = 256; - - /** - * Token sentinel for a {@code %} wildcard, which matches any run of characters (including none). - */ - private static final Object STAR = new Object(); - - /** - * LRU cache of parsed patterns. Parsing is comparatively cheap, but the LIKE pattern is usually a - * constant {@code LiteralOperand}, so caching avoids reparsing it for every event. Unlike a - * grow-then-stop cache, an LRU keeps caching new patterns by evicting the least-recently-used - * entry once full, so a workload with more than {@link #MAX_CACHED_PATTERNS} live patterns does - * not fall back to parsing on every event. - */ - private final Map patternCache = - Collections.synchronizedMap( - new LinkedHashMap<>(16, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_CACHED_PATTERNS; - } - }); + private final LikeMatcher matcher = new LikeMatcher(); Like() {} @@ -88,24 +59,13 @@ public Boolean apply( } try { - return matches(value, getTokens(pattern)); + return matcher.matches(value, pattern); } catch (IllegalArgumentException e) { + // A malformed pattern cannot match anything; FALSE rather than a filter error. return false; } } - private Object[] getTokens(String pattern) { - Object[] tokens = patternCache.get(pattern); - - if (tokens == null) { - // parse() throws IllegalArgumentException for malformed patterns; that propagates to apply(). - tokens = parse(pattern); - patternCache.put(pattern, tokens); - } - - return tokens; - } - @Nullable private static String asString(@Nullable Object value) { value = OperatorUtil.toScalarIfSingleElementArray(value); @@ -120,166 +80,4 @@ private static String asString(@Nullable Object value) { return null; } } - - /** - * Matches {@code text} against a parsed LIKE {@code pattern} using the classic iterative wildcard - * algorithm. It runs in O(text.length * pattern.length) time with O(1) extra state, so unlike a - * regex translation it cannot be driven into catastrophic backtracking (ReDoS) by a - * client-supplied pattern such as {@code %a%a%a...}. - */ - private static boolean matches(String text, Object[] tokens) { - int n = text.length(); - int m = tokens.length; - - int s = 0; // index into text - int t = 0; // index into tokens - int starToken = -1; // most recent STAR token index, or -1 if none seen - int starText = -1; // text index captured when that STAR was entered - - while (s < n) { - if (t < m && tokens[t] instanceof CharMatcher matcher && matcher.matches(text.charAt(s))) { - s++; - t++; - } else if (t < m && tokens[t] == STAR) { - starToken = t; - starText = s; - t++; - } else if (starToken == -1) { - return false; - } else { - // Backtrack: let the most recent STAR consume one more character and retry. - t = starToken + 1; - starText++; - s = starText; - } - } - - while (t < m && tokens[t] == STAR) { - t++; - } - - return t == m; - } - - /** - * Parses an OPC UA LIKE pattern (Part 4 Table 120) into a token array of {@link #STAR} markers - * and {@link CharMatcher}s. Throws {@link IllegalArgumentException} for malformed patterns. - */ - private static Object[] parse(String pattern) { - List tokens = new ArrayList<>(); - - for (int i = 0; i < pattern.length(); i++) { - char c = pattern.charAt(i); - - switch (c) { - case '%' -> { - // Consecutive '%' are semantically identical to a single one; collapse them. - if (tokens.isEmpty() || tokens.get(tokens.size() - 1) != STAR) { - tokens.add(STAR); - } - } - case '_' -> tokens.add((CharMatcher) ch -> true); - case '\\' -> { - if (++i >= pattern.length()) { - throw new IllegalArgumentException("trailing escape"); - } - char literal = pattern.charAt(i); - tokens.add((CharMatcher) ch -> ch == literal); - } - case '[' -> i = parseCharacterClass(pattern, i, tokens); - default -> tokens.add((CharMatcher) ch -> ch == c); - } - } - - return tokens.toArray(); - } - - /** - * Parses a {@code [...]} character class starting at {@code start} (the {@code '['}), appends a - * {@link CharMatcher} to {@code tokens}, and returns the index of the closing {@code ']'}. - */ - private static int parseCharacterClass(String pattern, int start, List tokens) { - int i = start + 1; - boolean negated = false; - - if (i < pattern.length() && pattern.charAt(i) == '^') { - negated = true; - i++; - } - - List ranges = new ArrayList<>(); - boolean sawContent = false; - - while (i < pattern.length()) { - char c = pattern.charAt(i); - - if (c == ']') { - if (!sawContent) { - throw new IllegalArgumentException("empty character class"); - } - - boolean finalNegated = negated; - tokens.add((CharMatcher) ch -> inRanges(ranges, ch) != finalNegated); - - return i; - } - - // Resolve the low end of a potential range, honoring '\' as a literal escape. - char lo; - if (c == '\\') { - if (++i >= pattern.length()) { - throw new IllegalArgumentException("trailing character class escape"); - } - lo = pattern.charAt(i); - } else { - lo = c; - } - - // A range is "x-y" where '-' is not the last character before ']'. A '-' adjacent to ']' is a - // literal '-'. - if (i + 2 < pattern.length() - && pattern.charAt(i + 1) == '-' - && pattern.charAt(i + 2) != ']') { - - i += 2; - char hi; - if (pattern.charAt(i) == '\\') { - if (++i >= pattern.length()) { - throw new IllegalArgumentException("trailing character class escape"); - } - hi = pattern.charAt(i); - } else { - hi = pattern.charAt(i); - } - - if (hi < lo) { - throw new IllegalArgumentException("invalid character range: " + lo + '-' + hi); - } - - ranges.add(new char[] {lo, hi}); - } else { - ranges.add(new char[] {lo, lo}); - } - - sawContent = true; - i++; - } - - throw new IllegalArgumentException("unclosed character class"); - } - - private static boolean inRanges(List ranges, char ch) { - for (char[] range : ranges) { - if (ch >= range[0] && ch <= range[1]) { - return true; - } - } - - return false; - } - - @FunctionalInterface - private interface CharMatcher { - boolean matches(char c); - } } diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/util/LikeMatcher.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/util/LikeMatcher.java new file mode 100644 index 0000000000..81b94f49df --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/util/LikeMatcher.java @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.NullMarked; + +/** + * Matches strings against OPC UA {@code Like} patterns (Part 4 §7.7.3, Table 120). + * + *

The pattern grammar: + * + *

    + *
  • {@code %} matches any run of characters, including none. + *
  • {@code _} matches exactly one character. + *
  • {@code \} escapes the next character, making it a literal. + *
  • {@code [...]} matches one character from a list and/or ranges, e.g. {@code [abc]} or {@code + * [a-z0-9]}; {@code [^...]} negates the class. Within a class, {@code \} escapes the next + * character. + *
  • Any other character matches itself. + *
+ * + *

Matching is case-sensitive and compares {@code char} values directly, with no Unicode + * normalization or locale-specific folding. + * + *

Instances are thread-safe: parsed patterns are held in an internal synchronized LRU cache, and + * matching itself is stateless. Each instance has its own cache, so independent subsystems can + * isolate their pattern working sets. + * + *

Malformed patterns are rejected with {@link IllegalArgumentException}; callers that need + * lenient behavior must catch it themselves. + */ +@NullMarked +public final class LikeMatcher { + + /** + * Upper bound on the number of distinct parsed patterns retained, so a client that sends many + * distinct patterns cannot grow the cache without bound. + */ + private static final int MAX_CACHED_PATTERNS = 256; + + /** + * Token sentinel for a {@code %} wildcard, which matches any run of characters (including none). + */ + private static final Object STAR = new Object(); + + /** + * LRU cache of parsed patterns. Parsing is comparatively cheap, but the same pattern is usually + * matched repeatedly, so caching avoids reparsing it for every match. Unlike a grow-then-stop + * cache, an LRU keeps caching new patterns by evicting the least-recently-used entry once full, + * so a workload with more than {@link #MAX_CACHED_PATTERNS} live patterns does not fall back to + * parsing on every match. + */ + private final Map patternCache = + Collections.synchronizedMap( + new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHED_PATTERNS; + } + }); + + /** Creates a matcher with its own (initially empty) pattern cache. */ + public LikeMatcher() {} + + /** + * Matches {@code value} against {@code pattern}. + * + * @param value the string to test. + * @param pattern the {@code Like} pattern to test against. + * @return {@code true} if {@code value} matches {@code pattern}. + * @throws IllegalArgumentException if {@code pattern} is malformed, e.g. it ends with a dangling + * escape, contains an empty or unclosed character class, or contains an inverted character + * range. + */ + public boolean matches(String value, String pattern) { + return matches(value, getTokens(pattern)); + } + + /** + * Parses {@code pattern} once and returns a compiled form for repeated matching. + * + *

Prefer this over {@link #matches(String, String)} when one pattern is matched against many + * values: the compiled form matches without touching the shared pattern cache, so a tight + * matching loop involves no synchronization. + * + * @param pattern the {@code Like} pattern to compile. + * @return the compiled form of {@code pattern}. + * @throws IllegalArgumentException if {@code pattern} is malformed; see {@link #matches(String, + * String)}. + */ + public CompiledPattern compile(String pattern) { + return new CompiledPattern(getTokens(pattern)); + } + + /** A parsed {@code Like} pattern; immutable and safe for concurrent matching. */ + public static final class CompiledPattern { + + private final Object[] tokens; + + private CompiledPattern(Object[] tokens) { + this.tokens = tokens; + } + + /** + * Matches {@code value} against this pattern. + * + * @param value the string to test. + * @return {@code true} if {@code value} matches this pattern. + */ + public boolean matches(String value) { + return LikeMatcher.matches(value, tokens); + } + } + + private Object[] getTokens(String pattern) { + Object[] tokens = patternCache.get(pattern); + + if (tokens == null) { + // parse() throws IllegalArgumentException for malformed patterns; that propagates to the + // caller. + tokens = parse(pattern); + patternCache.put(pattern, tokens); + } + + return tokens; + } + + /** + * Matches {@code text} against a parsed LIKE {@code pattern} using the classic iterative wildcard + * algorithm. It runs in O(text.length * pattern.length) time with O(1) extra state, so unlike a + * regex translation it cannot be driven into catastrophic backtracking (ReDoS) by a + * client-supplied pattern such as {@code %a%a%a...}. + */ + private static boolean matches(String text, Object[] tokens) { + int n = text.length(); + int m = tokens.length; + + int s = 0; // index into text + int t = 0; // index into tokens + int starToken = -1; // most recent STAR token index, or -1 if none seen + int starText = -1; // text index captured when that STAR was entered + + while (s < n) { + if (t < m && tokens[t] instanceof CharMatcher matcher && matcher.matches(text.charAt(s))) { + s++; + t++; + } else if (t < m && tokens[t] == STAR) { + starToken = t; + starText = s; + t++; + } else if (starToken == -1) { + return false; + } else { + // Backtrack: let the most recent STAR consume one more character and retry. + t = starToken + 1; + starText++; + s = starText; + } + } + + while (t < m && tokens[t] == STAR) { + t++; + } + + return t == m; + } + + /** + * Parses an OPC UA LIKE pattern (Part 4 Table 120) into a token array of {@link #STAR} markers + * and {@link CharMatcher}s. Throws {@link IllegalArgumentException} for malformed patterns. + */ + private static Object[] parse(String pattern) { + List tokens = new ArrayList<>(); + + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + + switch (c) { + case '%' -> { + // Consecutive '%' are semantically identical to a single one; collapse them. + if (tokens.isEmpty() || tokens.get(tokens.size() - 1) != STAR) { + tokens.add(STAR); + } + } + case '_' -> tokens.add((CharMatcher) ch -> true); + case '\\' -> { + if (++i >= pattern.length()) { + throw new IllegalArgumentException("trailing escape"); + } + char literal = pattern.charAt(i); + tokens.add((CharMatcher) ch -> ch == literal); + } + case '[' -> i = parseCharacterClass(pattern, i, tokens); + default -> tokens.add((CharMatcher) ch -> ch == c); + } + } + + return tokens.toArray(); + } + + /** + * Parses a {@code [...]} character class starting at {@code start} (the {@code '['}), appends a + * {@link CharMatcher} to {@code tokens}, and returns the index of the closing {@code ']'}. + */ + private static int parseCharacterClass(String pattern, int start, List tokens) { + int i = start + 1; + boolean negated = false; + + if (i < pattern.length() && pattern.charAt(i) == '^') { + negated = true; + i++; + } + + List ranges = new ArrayList<>(); + boolean sawContent = false; + + while (i < pattern.length()) { + char c = pattern.charAt(i); + + if (c == ']') { + if (!sawContent) { + throw new IllegalArgumentException("empty character class"); + } + + boolean finalNegated = negated; + tokens.add((CharMatcher) ch -> inRanges(ranges, ch) != finalNegated); + + return i; + } + + // Resolve the low end of a potential range, honoring '\' as a literal escape. + char lo; + if (c == '\\') { + if (++i >= pattern.length()) { + throw new IllegalArgumentException("trailing character class escape"); + } + lo = pattern.charAt(i); + } else { + lo = c; + } + + // A range is "x-y" where '-' is not the last character before ']'. A '-' adjacent to ']' is a + // literal '-'. + if (i + 2 < pattern.length() + && pattern.charAt(i + 1) == '-' + && pattern.charAt(i + 2) != ']') { + + i += 2; + char hi; + if (pattern.charAt(i) == '\\') { + if (++i >= pattern.length()) { + throw new IllegalArgumentException("trailing character class escape"); + } + hi = pattern.charAt(i); + } else { + hi = pattern.charAt(i); + } + + if (hi < lo) { + throw new IllegalArgumentException("invalid character range: " + lo + '-' + hi); + } + + ranges.add(new char[] {lo, hi}); + } else { + ranges.add(new char[] {lo, lo}); + } + + sawContent = true; + i++; + } + + throw new IllegalArgumentException("unclosed character class"); + } + + private static boolean inRanges(List ranges, char ch) { + for (char[] range : ranges) { + if (ch >= range[0] && ch <= range[1]) { + return true; + } + } + + return false; + } + + @FunctionalInterface + private interface CharMatcher { + boolean matches(char c); + } +} diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/util/LikeMatcherTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/util/LikeMatcherTest.java new file mode 100644 index 0000000000..afaf4c5202 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/util/LikeMatcherTest.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class LikeMatcherTest { + + private final LikeMatcher matcher = new LikeMatcher(); + + @Nested + class Wildcards { + + @Test + void percentMatchesAnyRunOfCharactersIncludingNone() { + // Part 4 §7.7.3 Table 120: '%' matches any string of zero or more characters. + assertTrue(matcher.matches("event message", "event%")); + assertTrue(matcher.matches("event", "event%")); + assertFalse(matcher.matches("message event", "event%")); + } + + @Test + void underscoreMatchesExactlyOneCharacter() { + // Part 4 §7.7.3 Table 120: '_' matches any single character; it never matches zero or two. + assertTrue(matcher.matches("ab", "a_")); + assertFalse(matcher.matches("a", "a_")); + assertFalse(matcher.matches("abc", "a_")); + } + + @Test + void consecutivePercentsBehaveLikeASinglePercent() { + // Part 4 §7.7.3: runs of '%' are semantically one wildcard; "a%%b" must match "ab". + assertTrue(matcher.matches("ab", "a%%b")); + assertTrue(matcher.matches("aXYb", "a%%b")); + } + } + + @Nested + class Escapes { + + @Test + void escapedWildcardsMatchLiterally() { + // Part 4 §7.7.3 Table 120: '\' escapes the following character, so "\%" and "\_" are the + // literal characters '%' and '_' rather than wildcards. + assertTrue(matcher.matches("event%", "event\\%")); + assertTrue(matcher.matches("event_", "event\\_")); + assertFalse(matcher.matches("eventX", "event\\_")); + } + + @Test + void escapedBackslashMatchesLiteralBackslash() { + // Part 4 §7.7.3: the escape character itself must be escapable, so "\\" is one literal '\'. + assertTrue(matcher.matches("a\\b", "a\\\\b")); + assertFalse(matcher.matches("ab", "a\\\\b")); + } + } + + @Nested + class CharacterClasses { + + @Test + void listMatchesAnySingleListedCharacter() { + // Part 4 §7.7.3 Table 120: "[abc]" matches any single character in the list. + assertTrue(matcher.matches("cat", "c[ao]t")); + assertTrue(matcher.matches("cot", "c[ao]t")); + assertFalse(matcher.matches("cut", "c[ao]t")); + } + + @Test + void rangeMatchesAnySingleCharacterWithinTheRange() { + // Part 4 §7.7.3 Table 120: "[a-f]" matches any single character in the inclusive range. + assertTrue(matcher.matches("cat", "c[a-f]t")); + assertTrue(matcher.matches("cft", "c[a-f]t")); + assertFalse(matcher.matches("czt", "c[a-f]t")); + } + + @Test + void negatedClassMatchesAnySingleCharacterOutsideTheClass() { + // Part 4 §7.7.3 Table 120: "[^...]" matches any single character NOT in the list/range. + assertTrue(matcher.matches("cot", "c[^a]t")); + assertFalse(matcher.matches("cat", "c[^a]t")); + } + + @Test + void escapedClassMetacharactersMatchLiterally() { + // Part 4 §7.7.3: inside a class, '\' escapes '-', ']', '^', and '\' so they lose their + // special meaning. + assertTrue(matcher.matches("-", "[a\\-c]")); + assertFalse(matcher.matches("b", "[a\\-c]")); + + assertTrue(matcher.matches("]", "[\\]]")); + assertFalse(matcher.matches("[", "[\\]]")); + + assertTrue(matcher.matches("^", "[\\^a]")); + assertFalse(matcher.matches("b", "[\\^a]")); + + assertTrue(matcher.matches("\\", "[\\\\]")); + assertFalse(matcher.matches("a", "[\\\\]")); + } + + @Test + void hyphenAdjacentToClosingBracketIsLiteral() { + // Part 4 §7.7.3: '-' only forms a range between two characters; before ']' it is a literal. + assertTrue(matcher.matches("-", "[a-]")); + assertTrue(matcher.matches("a", "[a-]")); + assertFalse(matcher.matches("b", "[a-]")); + } + } + + @Nested + class CaseSensitivity { + + @Test + void matchingIsCaseSensitive() { + // Part 4 §7.7.3 defines Like over character values with no case folding, so "event%" must + // not match a value that differs only in case. + assertFalse(matcher.matches("Event message", "event%")); + assertTrue(matcher.matches("Event message", "Event%")); + } + } + + @Nested + class MalformedPatterns { + + @Test + void unclosedCharacterClassThrows() { + // A '[' with no matching ']' is not valid Part 4 §7.7.3 grammar; the matcher must reject it + // rather than guess at intent. + assertThrows(IllegalArgumentException.class, () -> matcher.matches("abc", "a[b")); + } + + @Test + void trailingEscapeThrows() { + // A '\' with nothing to escape is not valid Part 4 §7.7.3 grammar. + assertThrows(IllegalArgumentException.class, () -> matcher.matches("abc", "abc\\")); + } + + @Test + void emptyCharacterClassThrows() { + // "[]" has no members; there is no character it could match, so it is rejected as malformed. + assertThrows(IllegalArgumentException.class, () -> matcher.matches("abc", "a[]c")); + } + + @Test + void trailingEscapeInsideCharacterClassThrows() { + // A '\' at the end of the pattern inside a class has nothing to escape. + assertThrows(IllegalArgumentException.class, () -> matcher.matches("abc", "a[b\\")); + } + + @Test + void invertedCharacterRangeThrows() { + // A range whose high end is below its low end matches nothing; it is rejected as malformed. + assertThrows(IllegalArgumentException.class, () -> matcher.matches("abc", "a[z-a]c")); + } + } + + @Nested + class Performance { + + @Test + void pathologicalPatternMatchesInLinearTime() { + // A regex translation of this pattern (many "%" separated by literals) would backtrack + // catastrophically against a long non-matching input; the iterative matcher must return + // promptly because Like patterns are client-supplied (ReDoS surface). + String value = "a".repeat(100_000); + + assertTimeoutPreemptively( + Duration.ofSeconds(5), + () -> assertFalse(matcher.matches(value, "%a%a%a%a%a%a%a%a%a%aZ"))); + } + } +} From 7b2d3a1b37aea74483809e1ea8cc782eef1be172 Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sun, 2 Aug 2026 16:50:01 -0700 Subject: [PATCH 2/3] Leave lifecycle stopped when onStartup throws startup() transitioned to RUNNING before invoking onStartup() and never reverted, so a failed startup left isRunning() reporting true and a defensive shutdown() would run onShutdown() against state that never finished initializing. Transition to STOPPED instead, making a lifecycle whose startup failed safely disposable. Co-Authored-By: Claude Fable 5 --- .../opcua/sdk/server/AbstractLifecycle.java | 11 ++- .../sdk/server/AbstractLifecycleTest.java | 93 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycleTest.java diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycle.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycle.java index f23d59f51d..f7bca13423 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycle.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycle.java @@ -21,6 +21,10 @@ public abstract class AbstractLifecycle implements Lifecycle { * *

Subsequent invocations throw {@link IllegalStateException}. * + *

If {@link #onStartup()} throws, the state transitions to stopped: {@link #isRunning()} is + * false, and a subsequent {@link #shutdown()} has no effect, so a lifecycle whose startup failed + * (and cleaned up after itself) is safely disposable. + * * @throws IllegalStateException on subsequent invocations. */ @Override @@ -29,7 +33,12 @@ public final void startup() throws IllegalStateException { state.getAndUpdate(prev -> prev == LifecycleState.NEW ? LifecycleState.RUNNING : prev); if (previous == LifecycleState.NEW) { - this.onStartup(); + try { + this.onStartup(); + } catch (Throwable t) { + state.set(LifecycleState.STOPPED); + throw t; + } } else { throw new IllegalStateException("cannot call startup when state=" + previous); } diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycleTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycleTest.java new file mode 100644 index 0000000000..da68ce1321 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/AbstractLifecycleTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Tests the state transitions of {@link AbstractLifecycle}, especially around startup failure. */ +class AbstractLifecycleTest { + + // A component whose onStartup throws has (per its own contract) cleaned up after itself; if + // the lifecycle stayed RUNNING, running-state guards throughout the component would pass and + // operate on the torn-down state. + @Test + void failedStartupLeavesTheLifecycleNotRunning() { + var lifecycle = new RecordingLifecycle(true); + + assertThrows(IllegalStateException.class, lifecycle::startup); + + assertFalse(lifecycle.isRunning()); + assertTrue(lifecycle.isNotRunning()); + } + + // Callers dispose components uniformly; disposing one whose startup failed must neither throw + // nor re-run shutdown logic against state the startup failure already rolled back. + @Test + void shutdownAfterFailedStartupIsANoOp() { + var lifecycle = new RecordingLifecycle(true); + + assertThrows(IllegalStateException.class, lifecycle::startup); + + lifecycle.shutdown(); + + assertFalse(lifecycle.onShutdownCalled); + } + + // A lifecycle whose startup failed is spent, like one that was shut down: the one-shot + // NEW -> RUNNING -> STOPPED progression does not restart. + @Test + void startupAfterFailedStartupThrows() { + var lifecycle = new RecordingLifecycle(true); + + assertThrows(IllegalStateException.class, lifecycle::startup); + + assertThrows(IllegalStateException.class, lifecycle::startup); + } + + @Test + void successfulStartupRunsAndShutdownStops() { + var lifecycle = new RecordingLifecycle(false); + + lifecycle.startup(); + assertTrue(lifecycle.isRunning()); + + lifecycle.shutdown(); + assertTrue(lifecycle.isNotRunning()); + assertTrue(lifecycle.onShutdownCalled); + } + + private static class RecordingLifecycle extends AbstractLifecycle { + + volatile boolean onShutdownCalled = false; + + private final boolean failOnStartup; + + RecordingLifecycle(boolean failOnStartup) { + this.failOnStartup = failOnStartup; + } + + @Override + protected void onStartup() { + if (failOnStartup) { + throw new IllegalStateException("startup failure"); + } + } + + @Override + protected void onShutdown() { + onShutdownCalled = true; + } + } +} From 7ef6e44e88eddf2d8a565214588687c8c1a90b1d Mon Sep 17 00:00:00 2001 From: Kevin Herron Date: Sun, 2 Aug 2026 16:50:11 -0700 Subject: [PATCH 3/3] Add OPC UA Part 17 Alias Names support AliasManager is an opt-in server component that binds FindAlias (and optionally FindAliasVerbose) on the standard Aliases, TagVariables, and Topics categories, supports application-defined category trees, and optionally exposes AddAliasesToCategory/DeleteAliasesFromCategory for client-driven configuration. LastChange versioning persists through a pluggable AliasVersionStore, and an AliasAuthorizationPolicy SPI gates calls and can filter individual results. Until a manager is installed, OpcUaNamespace now marks the standard FindAlias Methods non-executable so absent alias support is not a callable Method that always fails. Co-Authored-By: Claude Fable 5 --- .../examples/client/AliasNamesExample.java | 166 ++ .../milo/examples/server/ExampleServer.java | 79 +- .../AliasManagerWireEntryShutdownTest.java | 89 + .../sdk/server/aliases/package-info.java | 23 + .../test/aliases/AliasAdoptCategoryTest.java | 473 ++++ .../test/aliases/AliasConfigMethodsTest.java | 1309 ++++++++++ .../opcua/sdk/test/aliases/AliasFindTest.java | 671 +++++ .../aliases/AliasManagerLifecycleTest.java | 531 ++++ .../sdk/test/aliases/AliasMutationTest.java | 727 ++++++ .../sdk/test/aliases/AliasTestSupport.java | 78 + .../test/aliases/RecordingVersionStore.java | 85 + .../opcua/sdk/test/aliases/package-info.java | 31 + .../milo/opcua/sdk/core/Reference.java | 4 + .../AddAliasesToCategoryMethodImpl.java | 70 + .../aliases/AliasAuthorizationPolicy.java | 101 + .../sdk/server/aliases/AliasCategory.java | 36 + .../server/aliases/AliasCategoryConfig.java | 56 + .../opcua/sdk/server/aliases/AliasLimits.java | 59 + .../sdk/server/aliases/AliasManager.java | 2164 +++++++++++++++++ .../server/aliases/AliasManagerConfig.java | 280 +++ .../sdk/server/aliases/AliasSearchEngine.java | 496 ++++ .../opcua/sdk/server/aliases/AliasTarget.java | 57 + .../opcua/sdk/server/aliases/AliasTypes.java | 80 + .../server/aliases/AliasVersionManager.java | 360 +++ .../sdk/server/aliases/AliasVersionStore.java | 81 + .../DeleteAliasesFromCategoryMethodImpl.java | 68 + .../server/aliases/FindAliasMethodImpl.java | 68 + .../aliases/FindAliasVerboseMethodImpl.java | 68 + .../sdk/server/aliases/FindMethodSupport.java | 58 + .../aliases/InMemoryAliasVersionStore.java | 46 + .../sdk/server/aliases/package-info.java | 105 + .../sdk/server/namespaces/OpcUaNamespace.java | 38 +- .../opcua/sdk/server/nodes/UaMethodNode.java | 24 + .../aliases/AliasConfigValidationTest.java | 180 ++ .../aliases/AliasVersionManagerTest.java | 198 ++ .../InMemoryAliasVersionStoreTest.java | 129 + .../sdk/server/aliases/package-info.java | 17 + 37 files changed, 9091 insertions(+), 14 deletions(-) create mode 100644 milo-examples/client-examples/src/main/java/org/eclipse/milo/examples/client/AliasNamesExample.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerWireEntryShutdownTest.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasAdoptCategoryTest.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasConfigMethodsTest.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasFindTest.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasManagerLifecycleTest.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasMutationTest.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasTestSupport.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/RecordingVersionStore.java create mode 100644 opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/package-info.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AddAliasesToCategoryMethodImpl.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasAuthorizationPolicy.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategory.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategoryConfig.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasLimits.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManager.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerConfig.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasSearchEngine.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTarget.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTypes.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManager.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionStore.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/DeleteAliasesFromCategoryMethodImpl.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasMethodImpl.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasVerboseMethodImpl.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindMethodSupport.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/InMemoryAliasVersionStore.java create mode 100644 opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java create mode 100644 opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasConfigValidationTest.java create mode 100644 opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManagerTest.java create mode 100644 opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/InMemoryAliasVersionStoreTest.java create mode 100644 opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java diff --git a/milo-examples/client-examples/src/main/java/org/eclipse/milo/examples/client/AliasNamesExample.java b/milo-examples/client-examples/src/main/java/org/eclipse/milo/examples/client/AliasNamesExample.java new file mode 100644 index 0000000000..14cbd36aee --- /dev/null +++ b/milo-examples/client-examples/src/main/java/org/eclipse/milo/examples/client/AliasNamesExample.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.examples.client; + +import static java.util.Objects.requireNonNull; +import static java.util.Objects.requireNonNullElse; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.eclipse.milo.opcua.sdk.client.OpcUaClient; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; +import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult; +import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Looks up OPC UA Part 17 Alias Names on the example server: calls {@code FindAlias} and {@code + * FindAliasVerbose} on the standard {@code Aliases} Object with the pattern {@code "Demo.%"}, + * decodes the results, then reads the Value of a resolved target to prove end-to-end resolution. + * + *

The demo aliases are created by the ExampleServer's AliasManager in a "MiloDemo" category + * organized under the standard {@code TagVariables} Object; because the {@code FindAlias} search is + * recursive, they are found from the {@code Aliases} root. + */ +public class AliasNamesExample implements ClientExample { + + public static void main(String[] args) throws Exception { + AliasNamesExample example = new AliasNamesExample(); + + new ClientExampleRunner(example).run(); + } + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Override + public void run(OpcUaClient client, CompletableFuture future) throws Exception { + client.connect(); + + // FindAlias is a standard Method instance defined by the base NodeSet; the pattern argument + // uses the Part 4 "Like" grammar, so "Demo.%" matches every alias name starting "Demo.". + AliasNameDataType[] aliases = findAlias(client, "Demo.%"); + + for (AliasNameDataType alias : aliases) { + logger.info( + "FindAlias: {} -> {}", + alias.getAliasName().name(), + Arrays.toString(alias.getReferencedNodes())); + } + + if (aliases.length == 0) { + throw new UaException(StatusCodes.Bad_NotFound, "no aliases matched \"Demo.%\""); + } + + // Prove end-to-end resolution: take the first alias's first target (targets are returned in + // order of preference) and read its Value attribute. + readFirstTarget(client, aliases[0]); + + // FindAliasVerbose is an Optional Method the standard NodeSet does not define an instance of; + // the example server enables it on its AliasManager, which materializes the Method Node with + // a NodeId allocated in the example namespace. + NodeId findAliasVerboseId = NodeId.parse("ns=2;s=Aliases/FindAliasVerbose"); + + AliasNameVerboseDataType[] verboseAliases = + findAliasVerbose(client, findAliasVerboseId, "Demo.%"); + + for (AliasNameVerboseDataType alias : verboseAliases) { + logger.info( + "FindAliasVerbose: {} -> {} (category={})", + alias.getAliasName().name(), + Arrays.toString(alias.getReferencedNodes()), + alias.getAliasNameCategoryId()); + } + + future.complete(client); + } + + private AliasNameDataType[] findAlias(OpcUaClient client, String pattern) throws UaException { + ExtensionObject[] xos = callFindMethod(client, NodeIds.Aliases_FindAlias, pattern); + + var aliases = new AliasNameDataType[xos.length]; + for (int i = 0; i < xos.length; i++) { + aliases[i] = (AliasNameDataType) xos[i].decode(client.getStaticEncodingContext()); + } + return aliases; + } + + private AliasNameVerboseDataType[] findAliasVerbose( + OpcUaClient client, NodeId methodId, String pattern) throws UaException { + + ExtensionObject[] xos = callFindMethod(client, methodId, pattern); + + var aliases = new AliasNameVerboseDataType[xos.length]; + for (int i = 0; i < xos.length; i++) { + aliases[i] = (AliasNameVerboseDataType) xos[i].decode(client.getStaticEncodingContext()); + } + return aliases; + } + + /** + * Call a FindAlias-shaped Method on the standard {@code Aliases} Object and return the encoded + * result entries; both FindAlias and FindAliasVerbose take an alias name search pattern and a + * ReferenceType filter (a null NodeId means no restriction) and return a single output array. + */ + private ExtensionObject[] callFindMethod(OpcUaClient client, NodeId methodId, String pattern) + throws UaException { + + var request = + new CallMethodRequest( + NodeIds.Aliases, + methodId, + new Variant[] {new Variant(pattern), new Variant(NodeId.NULL_VALUE)}); + + CallResponse response = client.call(List.of(request)); + + CallMethodResult result = requireNonNull(response.getResults())[0]; + + if (!result.getStatusCode().isGood()) { + throw new UaException(result.getStatusCode()); + } + + Variant[] outputs = requireNonNull(result.getOutputArguments()); + + return requireNonNullElse((ExtensionObject[]) outputs[0].value(), new ExtensionObject[0]); + } + + private void readFirstTarget(OpcUaClient client, AliasNameDataType alias) throws UaException { + ExpandedNodeId firstTarget = requireNonNull(alias.getReferencedNodes())[0]; + + NodeId targetNodeId = + firstTarget + .toNodeId(client.getNamespaceTable()) + .orElseThrow( + () -> + new UaException( + StatusCodes.Bad_NodeIdUnknown, "target is not local: " + firstTarget)); + + DataValue value = client.readValue(0.0, TimestampsToReturn.Both, targetNodeId); + + logger.info( + "read {} (target {}): {}", + alias.getAliasName().name(), + targetNodeId.toParseableString(), + value.getValue().value()); + } +} diff --git a/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleServer.java b/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleServer.java index 310e19623e..76b3920b8a 100644 --- a/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleServer.java +++ b/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleServer.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -34,12 +35,18 @@ import org.eclipse.milo.opcua.sdk.server.OpcUaServer; import org.eclipse.milo.opcua.sdk.server.OpcUaServerConfig; import org.eclipse.milo.opcua.sdk.server.OpcUaServerConfigBuilder; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget; import org.eclipse.milo.opcua.sdk.server.identity.AnonymousIdentityValidator; import org.eclipse.milo.opcua.sdk.server.identity.CompositeValidator; import org.eclipse.milo.opcua.sdk.server.identity.UsernameIdentityValidator; import org.eclipse.milo.opcua.sdk.server.identity.X509IdentityValidator; import org.eclipse.milo.opcua.sdk.server.util.HostnameUtil; +import org.eclipse.milo.opcua.stack.core.NodeIds; import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; import org.eclipse.milo.opcua.stack.core.UaRuntimeException; import org.eclipse.milo.opcua.stack.core.security.AbstractCertificateFactory; import org.eclipse.milo.opcua.stack.core.security.DefaultApplicationGroup; @@ -52,6 +59,8 @@ import org.eclipse.milo.opcua.stack.core.transport.TransportProfile; import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime; import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode; import org.eclipse.milo.opcua.stack.core.types.structured.BuildInfo; import org.eclipse.milo.opcua.stack.core.util.CertificateUtil; @@ -93,6 +102,7 @@ public static void main(String[] args) throws Exception { private final OpcUaServer server; private final ExampleNamespace exampleNamespace; private final AlarmConditionsNamespace alarmConditionsNamespace; + private final AliasManager aliasManager; public ExampleServer() throws Exception { this(DEFAULT_TCP_BIND_PORT, builder -> {}); @@ -242,6 +252,53 @@ protected X509Certificate[] createRsaSha256CertificateChain(KeyPair keyPair) { alarmConditionsNamespace = new AlarmConditionsNamespace(server); alarmConditionsNamespace.startup(); + + // Opt-in OPC UA Part 17 Alias Names support: binds FindAlias on the standard Aliases, + // TagVariables, and Topics Objects and, with FindAliasVerbose enabled, materializes + // FindAliasVerbose Method instances alongside them, with NodeIds allocated in the example + // namespace. The manager is started in startup(), after the server itself has started. + aliasManager = + new AliasManager( + server, + AliasManagerConfig.builder() + .nodeNamespaceIndex(exampleNamespace.getNamespaceIndex()) + .findAliasVerboseEnabled(true) + .build()); + } + + /** + * Creates a demo alias category, "MiloDemo", organized under the standard {@code TagVariables} + * Object, with aliases targeting HelloWorld scalar Variables. The AliasNamesExample client + * example resolves these aliases via FindAlias/FindAliasVerbose and reads the targets. + */ + private void addDemoAliases() throws UaException { + var categoryConfig = + new AliasCategoryConfig( + new NodeId(exampleNamespace.getNamespaceIndex(), "Aliases/MiloDemo"), + NodeIds.TagVariables, + new QualifiedName(exampleNamespace.getNamespaceIndex().intValue(), "MiloDemo"), + exampleNamespace.getNodeManager(), + name -> new NodeId(exampleNamespace.getNamespaceIndex(), "Aliases/" + name), + false, + false, + false); + + NodeId categoryId = aliasManager.addCategory(categoryConfig).nodeId(); + + addDemoAlias(categoryId, "Demo.ScalarDouble", "HelloWorld/ScalarTypes/Double"); + addDemoAlias(categoryId, "Demo.ScalarInt32", "HelloWorld/ScalarTypes/Int32"); + } + + private void addDemoAlias(NodeId categoryId, String aliasName, String targetIdentifier) + throws UaException { + + var target = + new AliasTarget( + new NodeId(exampleNamespace.getNamespaceIndex(), targetIdentifier).expanded(), + null, + NodeIds.AliasFor); + + aliasManager.addAlias(categoryId, aliasName, List.of(target)); } private Set createEndpointConfigs(X509Certificate certificate) { @@ -319,10 +376,30 @@ public OpcUaServer getServer() { } public CompletableFuture startup() { - return server.startup(); + return server + .startup() + .thenApply( + s -> { + // The standard alias Objects and their FindAlias Method Nodes exist once the + // OpcUaServer is constructed, but the AliasManager is documented to start after + // the server itself has started. + aliasManager.startup(); + + try { + addDemoAliases(); + } catch (UaException e) { + throw new CompletionException(e); + } + + return s; + }); } public CompletableFuture shutdown() { + if (aliasManager.isRunning()) { + aliasManager.shutdown(); + } + alarmConditionsNamespace.shutdown(); exampleNamespace.shutdown(); diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerWireEntryShutdownTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerWireEntryShutdownTest.java new file mode 100644 index 0000000000..c7084e698c --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerWireEntryShutdownTest.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; +import org.junit.jupiter.api.Test; + +/** + * Tests that {@link AliasManager}'s wire entry points reject calls once the manager is no longer + * running. + * + *

Deliberately located in the manager's package: the guarded state is only reachable over the + * wire in the narrow race where a dispatched Call loses the manager-lock race to {@code shutdown()} + * (afterwards the Method Nodes are deleted, so a fresh Call never resolves them), which cannot be + * produced deterministically from a client. Calling the package-private entry points on a shut-down + * manager exercises exactly the state that race leaves behind. + */ +class AliasManagerWireEntryShutdownTest extends AbstractClientServerTest { + + // WHY: a Call dispatched just before shutdown() can pass authorization and then wait on the + // manager lock; if shutdown wins the lock first, the AddressSpace fragment is unregistered by + // the time the handler proceeds, so mutating would create ghost Nodes and persist spurious + // LastChange values. The entry point must fail with a defined code instead. + @Test + void addAliasEntriesOnShutDownManagerFailsWithBadInvalidState() { + AliasManager manager = newStartedManager(); + manager.shutdown(); + + UaException e = + assertThrows( + UaException.class, + () -> + manager.addAliasEntries( + NodeIds.Aliases, + new String[] {"PostShutdownAddAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeId.NULL_VALUE)); + + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidState), e.getStatusCode()); + } + + // WHY: the same mid-dispatch-vs-shutdown race exists for DeleteAliasesFromCategory, and the + // Delete path additionally touches application NodeManagers, so a post-shutdown call must fail + // with the same defined code instead of mutating ghost state. + @Test + void deleteAliasEntriesOnShutDownManagerFailsWithBadInvalidState() { + AliasManager manager = newStartedManager(); + manager.shutdown(); + + UaException e = + assertThrows( + UaException.class, + () -> + manager.deleteAliasEntries( + NodeIds.Aliases, new String[] {"PostShutdownDeleteAlias"}, null)); + + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidState), e.getStatusCode()); + } + + private AliasManager newStartedManager() { + AliasManager manager = + new AliasManager( + server, + AliasManagerConfig.builder() + .configurationEnabled(true) + .nodeNamespaceIndex(testNamespace.getNamespaceIndex()) + .build()); + manager.startup(); + return manager; + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java new file mode 100644 index 0000000000..d2453c7d3a --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/** + * White-box integration tests that must live in {@link + * org.eclipse.milo.opcua.sdk.server.aliases.AliasManager}'s own package to reach its + * package-private wire entry points against a running server. + * + *

Black-box alias integration tests, which exercise the manager purely through a client + * connection, live in {@code org.eclipse.milo.opcua.sdk.test.aliases}. Add tests here only when the + * scenario cannot be produced deterministically over the wire. + */ +@NullMarked +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.jspecify.annotations.NullMarked; diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasAdoptCategoryTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasAdoptCategoryTest.java new file mode 100644 index 0000000000..b3d9f4cc46 --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasAdoptCategoryTest.java @@ -0,0 +1,473 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.test.aliases; + +import static java.util.Objects.requireNonNull; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readLastChange; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.requireLastChange; +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.eclipse.milo.opcua.sdk.core.Reference; +import org.eclipse.milo.opcua.sdk.server.UaNodeManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategory; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget; +import org.eclipse.milo.opcua.sdk.server.methods.AbstractMethodInvocationHandler; +import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler; +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext; +import org.eclipse.milo.opcua.sdk.server.nodes.UaObjectNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaVariableNode; +import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; +import org.eclipse.milo.opcua.stack.core.types.structured.Argument; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult; +import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link AliasManager#adoptCategory}: managing an {@code + * AliasNameCategoryType} instance the application (e.g. a NodeSet loader) created, rather than one + * the manager instantiated itself. + * + *

Adoptable categories are built by hand in the test namespace — an Object with a {@code + * HasTypeDefinition} Reference to {@code AliasNameCategoryType} and component Method Nodes carrying + * the default {@code NotImplementedHandler} — mirroring what a NodeSet loader produces. An {@link + * AliasManager} is one-shot, so every test constructs a fresh instance against the shared server + * and shuts it down before returning; the hand-built category Nodes stay behind, with per-test + * names so tests remain order-independent. + */ +// Fields below are assigned in @BeforeAll, which the nullability inspection does not model. +@SuppressWarnings("NotNullFieldNotInitialized") +public class AliasAdoptCategoryTest extends AbstractClientServerTest { + + private UaNodeContext testNodeContext; + private UaNodeManager testNodeManager; + + @BeforeAll + void captureTestNamespaceInternals() { + testNamespace.configure( + (context, nodeManager) -> { + testNodeContext = context; + testNodeManager = nodeManager; + }); + } + + // WHY: the core adoption contract — a NodeSet-style category becomes searchable over the wire, + // and the aliases added to it live in the manager's own AddressSpace fragment (leaving with the + // manager at shutdown), because the manager must not write into Nodes it does not own beyond + // the adopted Method bindings. + @Test + void adoptionBindsFindAliasAndAliasesLiveInTheManagersFragment() throws UaException { + NodeId categoryId = buildCategory("AdoptedRoot", true, false, false, false); + NodeId findAliasNodeId = newNodeId("AdoptedRoot/FindAlias"); + NodeId aliasNodeId; + + AliasManager manager = newStartedManager(); + try { + AliasCategory category = manager.adoptCategory(categoryId); + + assertEquals(categoryId, category.nodeId()); + assertFalse(category.lastChangeEnabled()); + assertFalse(category.findAliasVerboseEnabled()); + assertFalse(category.configurationEnabled()); + + // The default alias NodeId factory allocates "/Alias/" in the + // configured Node namespace. + aliasNodeId = + manager.addAlias(categoryId, "AdoptedAlias", List.of(aliasFor(newNodeId("TestInt32")))); + assertEquals( + new NodeId( + testNamespace.getNamespaceIndex(), + categoryId.toParseableString() + "/Alias/AdoptedAlias"), + aliasNodeId); + + // Hosted in the manager's fragment, not the application NodeManager the category lives in. + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()); + assertFalse(testNodeManager.containsNode(aliasNodeId)); + + // The adopted FindAlias is callable over the wire and finds the alias. + CallMethodResult result = callFind(categoryId, findAliasNodeId); + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertEquals(1, manager.findAlias(categoryId, "AdoptedAlias", null).size()); + } finally { + manager.shutdown(); + } + + // Shutdown removed the fragment-hosted alias but left the application's category Nodes, + // returning the adopted FindAlias to its unbound, non-executable state. + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isEmpty()); + assertTrue(server.getAddressSpaceManager().getManagedNode(categoryId).isPresent()); + + UaMethodNode findAliasNode = serverMethodNode(findAliasNodeId); + assertInstanceOf( + MethodInvocationHandler.NotImplementedHandler.class, findAliasNode.getInvocationHandler()); + assertFalse(findAliasNode.isExecutable()); + assertFalse(findAliasNode.isUserExecutable()); + } + + // WHY: a pre-existing FindAliasVerbose instance is the category definition's decision to offer + // verbose lookup; adoption must give it behavior like the other optional Methods instead of + // leaving a permanently non-executable Method, and the returned handle must report it. + @Test + void adoptionBindsPresentUnboundFindAliasVerbose() throws UaException { + NodeId categoryId = buildCategory("AdoptedVerbose", true, true, false, false); + NodeId verboseNodeId = newNodeId("AdoptedVerbose/FindAliasVerbose"); + + AliasManager manager = newStartedManager(); + try { + AliasCategory category = manager.adoptCategory(categoryId); + assertTrue(category.findAliasVerboseEnabled()); + + manager.addAlias( + categoryId, "AdoptedVerboseAlias", List.of(aliasFor(newNodeId("TestInt32")))); + + CallMethodResult result = callFind(categoryId, verboseNodeId); + assertEquals(StatusCode.GOOD, result.getStatusCode()); + } finally { + manager.shutdown(); + } + + UaMethodNode verboseNode = serverMethodNode(verboseNodeId); + assertInstanceOf( + MethodInvocationHandler.NotImplementedHandler.class, verboseNode.getInvocationHandler()); + assertFalse(verboseNode.isExecutable()); + } + + // WHY: pre-existing mutation Method Nodes are bound opportunistically and reported through + // configurationEnabled; the two-layer deny posture still applies (the default policy denies + // every session), so binding alone must not open network mutation. + @Test + void adoptionBindsPresentUnboundMutationMethodsAndReportsConfigurationEnabled() + throws UaException { + NodeId categoryId = buildCategory("AdoptedMutable", true, false, true, false); + NodeId addMethodNodeId = newNodeId("AdoptedMutable/AddAliasesToCategory"); + + AliasManager manager = newStartedManager(); + try { + AliasCategory category = manager.adoptCategory(categoryId); + + assertTrue(category.configurationEnabled()); + + UaMethodNode addMethodNode = serverMethodNode(addMethodNodeId); + assertFalse( + addMethodNode.getInvocationHandler() + instanceof MethodInvocationHandler.NotImplementedHandler); + assertTrue(addMethodNode.isExecutable()); + } finally { + manager.shutdown(); + } + } + + // WHY: "bound only if still unbound" — an optional Method another component already gave + // behavior must be left untouched by adoption AND by the manager's shutdown, and the returned + // handle must not claim it. + @Test + void adoptionLeavesAlreadyBoundFindAliasVerboseUntouched() throws UaException { + NodeId categoryId = buildCategory("AdoptedForeignVerbose", true, true, false, false); + UaMethodNode verboseNode = + serverMethodNode(newNodeId("AdoptedForeignVerbose/FindAliasVerbose")); + + MethodInvocationHandler foreignHandler = dummyHandler(verboseNode); + verboseNode.setInvocationHandler(foreignHandler); + try { + AliasManager manager = newStartedManager(); + try { + AliasCategory category = manager.adoptCategory(categoryId); + + assertFalse(category.findAliasVerboseEnabled()); + assertSame(foreignHandler, verboseNode.getInvocationHandler()); + } finally { + manager.shutdown(); + } + + // Shutdown unbinds only what adoption bound; the foreign handler survives. + assertSame(foreignHandler, verboseNode.getInvocationHandler()); + } finally { + verboseNode.setInvocationHandler(MethodInvocationHandler.NOT_IMPLEMENTED); + } + } + + // WHY: an adopted category with a LastChange Property participates in §6.3.1 version + // maintenance: the handle reports it and mutations through the manager publish a value. + @Test + void adoptedCategoryWithLastChangePropertyGetsVersionMaintenance() throws UaException { + NodeId categoryId = buildCategory("AdoptedVersioned", true, false, false, true); + + AliasManager manager = newStartedManager(); + try { + AliasCategory category = manager.adoptCategory(categoryId); + assertTrue(category.lastChangeEnabled()); + + assertNull(readLastChange(server, categoryId)); + + manager.addAlias( + categoryId, "AdoptedVersionedAlias", List.of(aliasFor(newNodeId("TestInt32")))); + + requireLastChange(server, categoryId); + } finally { + manager.shutdown(); + } + } + + // WHY: the five documented adoption failure codes, each of which must reject the call without + // managing the category (adoptCategory validates before binding anything). + + @Test + void adoptingAnAlreadyManagedCategoryFailsWithBadNodeIdExists() throws UaException { + NodeId categoryId = buildCategory("AdoptedTwice", true, false, false, false); + + AliasManager manager = newStartedManager(); + try { + manager.adoptCategory(categoryId); + + UaException e = assertThrows(UaException.class, () -> manager.adoptCategory(categoryId)); + assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdExists), e.getStatusCode()); + } finally { + manager.shutdown(); + } + } + + @Test + void adoptingAStandardCategoryFailsWithBadInvalidArgument() { + AliasManager manager = newStartedManager(); + try { + UaException e = + assertThrows(UaException.class, () -> manager.adoptCategory(NodeIds.TagVariables)); + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), e.getStatusCode()); + } finally { + manager.shutdown(); + } + } + + @Test + void adoptingANonexistentNodeFailsWithBadNodeIdUnknown() { + AliasManager manager = newStartedManager(); + try { + UaException e = + assertThrows(UaException.class, () -> manager.adoptCategory(newNodeId("NoSuchCategory"))); + assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdUnknown), e.getStatusCode()); + } finally { + manager.shutdown(); + } + } + + @Test + void adoptingANonCategoryNodeFailsWithBadNodeIdUnknown() { + AliasManager manager = newStartedManager(); + try { + UaException e = + assertThrows(UaException.class, () -> manager.adoptCategory(newNodeId("TestInt32"))); + assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdUnknown), e.getStatusCode()); + } finally { + manager.shutdown(); + } + } + + @Test + void adoptingACategoryWithoutFindAliasFailsWithBadNotFound() throws UaException { + NodeId categoryId = buildCategory("AdoptedNoFindAlias", false, false, false, false); + + AliasManager manager = newStartedManager(); + try { + UaException e = assertThrows(UaException.class, () -> manager.adoptCategory(categoryId)); + assertEquals(StatusCode.of(StatusCodes.Bad_NotFound), e.getStatusCode()); + } finally { + manager.shutdown(); + } + } + + @Test + void adoptingACategoryWhoseFindAliasIsAlreadyBoundFailsWithBadInvalidState() throws UaException { + NodeId categoryId = buildCategory("AdoptedConflicted", true, false, false, false); + UaMethodNode findAliasNode = serverMethodNode(newNodeId("AdoptedConflicted/FindAlias")); + + findAliasNode.setInvocationHandler(dummyHandler(findAliasNode)); + try { + AliasManager manager = newStartedManager(); + try { + UaException e = assertThrows(UaException.class, () -> manager.adoptCategory(categoryId)); + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidState), e.getStatusCode()); + } finally { + manager.shutdown(); + } + } finally { + findAliasNode.setInvocationHandler(MethodInvocationHandler.NOT_IMPLEMENTED); + } + } + + /** + * Build a NodeSet-style adoptable category in the test namespace: an Object typed {@code + * AliasNameCategoryType}, organized under the standard root {@code Aliases} Object, with the + * requested component Method Nodes (all carrying the default {@code NotImplementedHandler}) and + * optionally a {@code LastChange} Property. + */ + private NodeId buildCategory( + String name, + boolean withFindAlias, + boolean withFindAliasVerbose, + boolean withAddMethod, + boolean withLastChange) { + + NodeId categoryId = newNodeId(name); + + var categoryNode = + new UaObjectNode( + testNodeContext, + categoryId, + newQualifiedName(name), + LocalizedText.english(name), + LocalizedText.NULL_VALUE, + uint(0), + uint(0)); + testNodeManager.addNode(categoryNode); + + categoryNode.addReference( + new Reference( + categoryId, + NodeIds.HasTypeDefinition, + NodeIds.AliasNameCategoryType.expanded(), + Reference.Direction.FORWARD)); + + categoryNode.addReference( + new Reference( + categoryId, + NodeIds.Organizes, + NodeIds.Aliases.expanded(), + Reference.Direction.INVERSE)); + + if (withFindAlias) { + buildComponentMethod(categoryId, name, "FindAlias"); + } + if (withFindAliasVerbose) { + buildComponentMethod(categoryId, name, "FindAliasVerbose"); + } + if (withAddMethod) { + buildComponentMethod(categoryId, name, "AddAliasesToCategory"); + } + + if (withLastChange) { + var propertyNode = + new UaVariableNode.UaVariableNodeBuilder(testNodeContext) + .setNodeId(newNodeId(name + "/LastChange")) + .setBrowseName(new QualifiedName(0, "LastChange")) + .setDisplayName(LocalizedText.english("LastChange")) + .setDataType(NodeIds.VersionTime) + .setTypeDefinition(NodeIds.PropertyType) + .build(); + testNodeManager.addNode(propertyNode); + + propertyNode.addReference( + new Reference( + propertyNode.getNodeId(), + NodeIds.HasProperty, + categoryId.expanded(), + Reference.Direction.INVERSE)); + } + + return categoryId; + } + + /** Build an unbound Method Node named {@code methodName} as a component of the category. */ + private void buildComponentMethod(NodeId categoryId, String categoryName, String methodName) { + UaMethodNode.build( + testNodeContext, + b -> { + b.setNodeId(newNodeId(categoryName + "/" + methodName)); + b.setBrowseName(new QualifiedName(0, methodName)); + b.setDisplayName(LocalizedText.english(methodName)); + + b.addReference( + new Reference( + b.getNodeId(), + NodeIds.HasComponent, + categoryId.expanded(), + Reference.Direction.INVERSE)); + + return b.buildAndAdd(); + }); + } + + private AliasManager newStartedManager() { + AliasManager manager = + new AliasManager( + server, + AliasManagerConfig.builder() + .nodeNamespaceIndex(testNamespace.getNamespaceIndex()) + .build()); + manager.startup(); + return manager; + } + + private AliasTarget aliasFor(NodeId targetNodeId) { + return new AliasTarget(targetNodeId.expanded(), null, NodeIds.AliasFor); + } + + /** A handler standing in for "some other component already bound this Method". */ + private static AbstractMethodInvocationHandler dummyHandler(UaMethodNode methodNode) { + return new AbstractMethodInvocationHandler(methodNode) { + @Override + public Argument[] getInputArguments() { + return new Argument[0]; + } + + @Override + public Argument[] getOutputArguments() { + return new Argument[0]; + } + + @Override + protected Variant[] invoke(InvocationContext invocationContext, Variant[] inputValues) { + return new Variant[0]; + } + }; + } + + private UaMethodNode serverMethodNode(NodeId nodeId) { + UaNode node = server.getAddressSpaceManager().getManagedNode(nodeId).orElseThrow(); + return assertInstanceOf(UaMethodNode.class, node); + } + + /** + * Call {@code FindAlias}/{@code FindAliasVerbose} on {@code objectId} through the client with + * pattern {@code "%"} and no ReferenceType filter. + */ + private CallMethodResult callFind(NodeId objectId, NodeId methodId) throws UaException { + CallResponse response = + client.call( + List.of( + new CallMethodRequest( + objectId, + methodId, + new Variant[] {new Variant("%"), new Variant(NodeId.NULL_VALUE)}))); + + return requireNonNull(response.getResults())[0]; + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasConfigMethodsTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasConfigMethodsTest.java new file mode 100644 index 0000000000..247c5d06cf --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasConfigMethodsTest.java @@ -0,0 +1,1309 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.test.aliases; + +import static java.util.Objects.requireNonNull; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.assertStrictlyGreater; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readAttribute; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readLastChange; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.requireLastChange; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.milo.opcua.sdk.core.Reference; +import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite; +import org.eclipse.milo.opcua.sdk.server.AddressSpaceFilter; +import org.eclipse.milo.opcua.sdk.server.ManagedAddressSpaceFragmentWithLifecycle; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.sdk.server.Session; +import org.eclipse.milo.opcua.sdk.server.SimpleAddressSpaceFilter; +import org.eclipse.milo.opcua.sdk.server.UaNodeManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasAuthorizationPolicy; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategory; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasLimits; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasVersionStore; +import org.eclipse.milo.opcua.sdk.server.items.DataItem; +import org.eclipse.milo.opcua.sdk.server.items.MonitoredItem; +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest; +import org.eclipse.milo.opcua.stack.core.AttributeId; +import org.eclipse.milo.opcua.stack.core.NamespaceTable; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult; +import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Integration tests for {@link AliasManager}'s network mutation path: the {@code + * AddAliasesToCategory} and {@code DeleteAliasesFromCategory} Methods (Part 17 §6.3.4/§6.3.5) + * exercised over the wire with real client Calls — the two independent deny layers (Method + * materialization and authorization policy), the per-entry StatusCode contract, call-level + * validation, and the per-category configuration flag. + * + *

An {@link AliasManager} is one-shot and only one at a time may manage the standard alias + * Objects, so each test group starts its own manager (per test, or per nested class through its own + * lifecycle methods) and shuts it down when done. Alias Nodes the wire path creates in standard + * categories are hosted in the manager's AddressSpace fragment and leave the AddressSpace with it, + * so groups do not observe each other's aliases. + */ +// Fields here and in the nested classes are assigned in @BeforeAll/@BeforeEach, which the +// nullability inspection does not model. +@SuppressWarnings("NotNullFieldNotInitialized") +class AliasConfigMethodsTest extends AbstractClientServerTest { + + /** + * The operations-per-call limit configured by {@link #grantedConfig}; small so oversized-array + * behavior is testable with a handful of entries. + */ + private static final int MAX_OPERATIONS_PER_CALL = 4; + + /** Grants find and mutate to every session; the opposite of the deny-by-default default. */ + private static final AliasAuthorizationPolicy ALLOW_FIND_AND_MUTATE = + new AliasAuthorizationPolicy() { + @Override + public boolean checkFind(@Nullable Session session, NodeId categoryId) { + return true; + } + + @Override + public boolean checkMutate(@Nullable Session session, NodeId categoryId) { + return true; + } + }; + + private UaNodeManager testNodeManager; + + @BeforeAll + void captureTestNodeManager() { + testNamespace.configure((context, nodeManager) -> testNodeManager = nodeManager); + } + + @Nested + class ConfigurationDisabled { + + // WHY: the first deny layer of the two-layer mutation posture — Part 17 §6.3.1 marks the + // mutation Methods Optional, and the manager only materializes them when configuration is + // enabled. With the default (disabled) config the Nodes must be entirely absent: a Read fails + // with Bad_NodeIdUnknown and a Call fails Method resolution with Bad_MethodInvalid instead of + // reaching any handler. + @Test + void mutationMethodNodesAreAbsentWithDefaultConfig() throws UaException { + AliasManager manager = + new AliasManager( + server, + AliasManagerConfig.builder() + .nodeNamespaceIndex(testNamespace.getNamespaceIndex()) + .build()); + manager.startup(); + try { + for (NodeId methodNodeId : List.of(aliasesAddMethodId(), aliasesDeleteMethodId())) { + DataValue value = readAttribute(client, methodNodeId, AttributeId.BrowseName); + assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdUnknown), value.getStatusCode()); + } + + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"AbsentMethodAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.of(StatusCodes.Bad_MethodInvalid), result.getStatusCode()); + } finally { + manager.shutdown(); + } + } + } + + @Nested + class MutationDeniedByDefaultPolicy { + + // WHY: the second deny layer is independent of materialization — with the Methods + // materialized (configurationEnabled true) but the DEFAULT authorization policy in place, + // every session is denied mutation: the Call fails with Bad_UserAccessDenied (the code Part + // 17 §6.3.4/§6.3.5 name for a caller without rights) and the category is unchanged. + @Test + void addAndDeleteCallsAreDeniedWithTheDefaultPolicy() throws UaException { + AliasManager manager = + new AliasManager( + server, + AliasManagerConfig.builder() + .configurationEnabled(true) + .nodeNamespaceIndex(testNamespace.getNamespaceIndex()) + .build()); + manager.startup(); + try { + CallMethodResult addResult = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"DeniedWireAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.of(StatusCodes.Bad_UserAccessDenied), addResult.getStatusCode()); + assertTrue(manager.findAlias(NodeIds.Aliases, "DeniedWireAlias", null).isEmpty()); + + CallMethodResult deleteResult = + callDeleteAliases( + NodeIds.Aliases, aliasesDeleteMethodId(), new String[] {"DeniedWireAlias"}, null); + + assertEquals(StatusCode.of(StatusCodes.Bad_UserAccessDenied), deleteResult.getStatusCode()); + } finally { + manager.shutdown(); + } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class VersionPersistenceFailure { + + /** + * An in-memory store whose saves can be switched to fail — all of them, or all after the next N + * successes — simulating persistence that breaks after a successful startup or partway through + * a multi-category operation. + */ + final class TogglableStore implements AliasVersionStore { + + private final Map entries = new HashMap<>(); + + volatile boolean failSaves = false; + + /** When non-negative: this many further saves succeed, then every save fails. */ + volatile int failAfterSaves = -1; + + @Override + public Map load() { + return Map.copyOf(entries); + } + + @Override + public void save(ExpandedNodeId categoryId, UInteger value) throws UaException { + if (failSaves || failAfterSaves == 0) { + throw new UaException(StatusCodes.Bad_ResourceUnavailable, "save failed"); + } + if (failAfterSaves > 0) { + failAfterSaves--; + } + entries.put(categoryId, value); + } + } + + private TogglableStore store; + private AliasManager aliasManager; + + @BeforeAll + void startManager() { + store = new TogglableStore(); + aliasManager = new AliasManager(server, grantedConfig(store)); + aliasManager.startup(); + } + + @AfterAll + void shutdownManager() { + aliasManager.shutdown(); + } + + // WHY: save-before-mutate — §6.3.1's persisted-LastChange contract only holds if no + // observable version was ever unpersisted, so an entry whose LastChange save fails must fail + // with Bad_InternalError BEFORE creating anything: were the alias created anyway, a restart + // could re-produce an already-observed LastChange value for different content and Client + // caches would go undetectably stale. + @Test + void wireEntryWhoseVersionSaveFailsIsRejectedWithoutCreatingTheAlias() throws UaException { + UInteger lastChangeBefore = requireLastChange(server, NodeIds.Aliases); + + store.failSaves = true; + try { + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"UnpersistableWireAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals( + new StatusCode[] {StatusCode.of(StatusCodes.Bad_InternalError)}, errorCodes(result)); + } finally { + store.failSaves = false; + } + + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "UnpersistableWireAlias", null).isEmpty()); + assertEquals(lastChangeBefore, requireLastChange(server, NodeIds.Aliases)); + } + + // WHY: the same guarantee on the programmatic path — addAlias must throw Bad_InternalError + // with nothing applied when the LastChange save fails, and the identical call must succeed + // once persistence recovers, proving the failed attempt left no residue behind. + @Test + void programmaticAddAliasFailsCleanlyWhenVersionSaveFailsAndRecovers() throws UaException { + UInteger lastChangeBefore = requireLastChange(server, NodeIds.Aliases); + var target = new AliasTarget(newNodeId("TestInt32").expanded(), null, NodeIds.AliasFor); + + store.failSaves = true; + try { + UaException e = + assertThrows( + UaException.class, + () -> + aliasManager.addAlias(NodeIds.Aliases, "UnpersistableAlias", List.of(target))); + + assertEquals(StatusCode.of(StatusCodes.Bad_InternalError), e.getStatusCode()); + } finally { + store.failSaves = false; + } + + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "UnpersistableAlias", null).isEmpty()); + assertEquals(lastChangeBefore, requireLastChange(server, NodeIds.Aliases)); + + aliasManager.addAlias(NodeIds.Aliases, "UnpersistableAlias", List.of(target)); + assertEquals(1, aliasManager.findAlias(NodeIds.Aliases, "UnpersistableAlias", null).size()); + } + + // WHY: the prepare/publish pairing invariant — a removeCategory whose ancestor-version save + // fails partway must abort with nothing removed, still publish the values it persisted + // before the failure (a bump without a content change is the safe direction), and leave + // nothing stranded in the pending set: the next mutation must compute a fresh version, not + // republish a stale prepared one. + @Test + void removeCategoryAncestorSaveFailurePartwayAbortsAndDrainsPending() throws UaException { + AliasCategory parent = + aliasManager.addCategory(categoryConfig("PendingDrainParent", NodeIds.Aliases)); + AliasCategory child = + aliasManager.addCategory(categoryConfig("PendingDrainChild", parent.nodeId())); + + UInteger parentBefore = requireLastChange(server, parent.nodeId()); + + // Removing the child prepares its ancestors in discovery order [parent, root Aliases]: + // let the parent's save through and fail the root's. + store.failAfterSaves = 1; + UaException e; + try { + e = assertThrows(UaException.class, () -> aliasManager.removeCategory(child.nodeId())); + } finally { + store.failAfterSaves = -1; + } + assertEquals(StatusCode.of(StatusCodes.Bad_InternalError), e.getStatusCode()); + + // Nothing was removed: the category Node still exists and is still managed (the retry + // during cleanup below succeeds). + assertTrue(server.getAddressSpaceManager().getManagedNode(child.nodeId()).isPresent()); + + // The parent's persisted-before-the-failure value was still published... + UInteger parentAfterFailure = requireLastChange(server, parent.nodeId()); + assertStrictlyGreater(parentBefore, parentAfterFailure); + + // ...and drained from the pending set: the next mutation computes a fresh version. + aliasManager.addAlias( + parent.nodeId(), + "PendingDrainAlias", + List.of(new AliasTarget(newNodeId("TestInt32").expanded(), null, NodeIds.AliasFor))); + assertStrictlyGreater(parentAfterFailure, readLastChange(server, parent.nodeId())); + + aliasManager.deleteAlias(parent.nodeId(), "PendingDrainAlias", null); + aliasManager.removeCategory(child.nodeId()); + aliasManager.removeCategory(parent.nodeId()); + } + + private AliasCategoryConfig categoryConfig(String name, NodeId parentCategoryId) { + return new AliasCategoryConfig( + newNodeId(name), + parentCategoryId, + newQualifiedName(name), + testNodeManager, + aliasName -> newNodeId(name + "/" + aliasName), + true, + false, + false); + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class AddAliasesToCategoryCalls { + + private RecordingVersionStore versionStore; + private AliasManager aliasManager; + + @BeforeAll + void startManager() { + versionStore = new RecordingVersionStore(server); + aliasManager = new AliasManager(server, grantedConfig(versionStore)); + aliasManager.startup(); + } + + @AfterAll + void shutdownManager() { + aliasManager.shutdown(); + } + + // WHY: Part 17 §6.3.4 — AddAliasesToCategory reports one StatusCode per entry, parallel to + // the inputs, and the created aliases must be visible to FindAlias on the same category. + // §6.3.1 requires the change to surface through LastChange; the manager's contract is a + // single version bump per call — persisted exactly once — no matter how many entries changed + // the category. + @Test + void addCallCreatesAliasesAndBumpsLastChangeOncePerCall() throws UaException { + UInteger lastChangeBefore = requireLastChange(server, NodeIds.Aliases); + long savesBefore = versionStore.savesFor(NodeIds.Aliases); + + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"WireAddedA", "WireAddedB"}, + new ExpandedNodeId[] { + newNodeId("TestInt32").expanded(), newNodeId("TestAnalogValue").expanded() + }, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals(new StatusCode[] {StatusCode.GOOD, StatusCode.GOOD}, errorCodes(result)); + + List foundA = findRootAliases("WireAddedA"); + assertEquals(1, foundA.size()); + assertArrayEquals( + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + foundA.get(0).getReferencedNodes()); + + List foundB = findRootAliases("WireAddedB"); + assertEquals(1, foundB.size()); + assertArrayEquals( + new ExpandedNodeId[] {newNodeId("TestAnalogValue").expanded()}, + foundB.get(0).getReferencedNodes()); + + assertStrictlyGreater(lastChangeBefore, readLastChange(server, NodeIds.Aliases)); + assertEquals(savesBefore + 1, versionStore.savesFor(NodeIds.Aliases)); + } + + // WHY: Part 17 §6.3.4 — results are per entry and a failed entry must not affect any other + // entry: an unknown local TargetNode fails its own entry with Bad_NodeIdUnknown while the + // sibling entry in the SAME call still creates its alias. + @Test + void failedEntryDoesNotAffectSiblingEntryInSameCall() throws UaException { + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"MissingTargetAlias", "IsolatedGoodAlias"}, + new ExpandedNodeId[] { + newNodeId("NoSuchTargetNode").expanded(), newNodeId("TestInt32").expanded() + }, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals( + new StatusCode[] {StatusCode.of(StatusCodes.Bad_NodeIdUnknown), StatusCode.GOOD}, + errorCodes(result)); + + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "MissingTargetAlias", null).isEmpty()); + assertEquals(1, aliasManager.findAlias(NodeIds.Aliases, "IsolatedGoodAlias", null).size()); + } + + // WHY: Part 17 §6.3.4 — an entry duplicating an existing alias/target association "shall be + // ignored and no error shall be generated": the repeat call reports Good, adds no Reference, + // and neither bumps nor persists LastChange because nothing changed. + @Test + void duplicateOfExistingAssociationIsIgnoredWithGood() throws UaException { + addRootAliasOverWire("DupWireAlias", newNodeId("TestInt32")); + + UInteger lastChangeBefore = requireLastChange(server, NodeIds.Aliases); + long savesBefore = versionStore.savesFor(NodeIds.Aliases); + + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"DupWireAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(result)); + + List found = findRootAliases("DupWireAlias"); + assertEquals(1, found.size()); + assertArrayEquals( + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + found.get(0).getReferencedNodes()); + + assertEquals(lastChangeBefore, readLastChange(server, NodeIds.Aliases)); + assertEquals(savesBefore, versionStore.savesFor(NodeIds.Aliases)); + } + + // WHY: Part 17 §6.3.4 — idempotency extends to duplicates within one request: an entry that + // repeats an earlier entry of the same call must be ignored with Good, leaving exactly one + // AliasFor association behind. + @Test + void repeatedEntryWithinOneCallIsIgnoredWithGood() throws UaException { + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"IntraDupAlias", "IntraDupAlias"}, + new ExpandedNodeId[] { + newNodeId("TestInt32").expanded(), newNodeId("TestInt32").expanded() + }, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals(new StatusCode[] {StatusCode.GOOD, StatusCode.GOOD}, errorCodes(result)); + + List found = findRootAliases("IntraDupAlias"); + assertEquals(1, found.size()); + assertArrayEquals( + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + found.get(0).getReferencedNodes()); + } + + // WHY: Part 17 §6.3.4 defines Bad_NotSupported for a server that does not support aliases + // with targets on remote Servers, which this manager does not; a non-empty TargetServers + // entry marks its target remote, so the entry fails and nothing is created. + // (Uncertain_ReferenceOutOfServer is reserved for servers that ACCEPT a remote target they + // cannot verify.) + @Test + void remoteTargetEntryIsRejectedWithBadNotSupported() throws UaException { + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"RemoteTargetWireAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[] {"urn:test:remote-server"}, + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals( + new StatusCode[] {StatusCode.of(StatusCodes.Bad_NotSupported)}, errorCodes(result)); + + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "RemoteTargetWireAlias", null).isEmpty()); + } + + // WHY: Part 17 §6.3.4 — "The ServerIndex in the ExpandedNodeId shall be ignored and the + // TargetServers Uri shall be used": with a null/empty TargetServers entry the target is + // local, so a wire ExpandedNodeId carrying serverIndex=1 must still resolve to the local + // Node instead of the ServerIndex being treated as a remote-target signal. + @Test + void serverIndexInTargetNodeIsIgnoredWhenTargetServersEntryIsEmpty() throws UaException { + NodeId localTarget = newNodeId("TestInt32"); + var targetWithServerIndex = + new ExpandedNodeId( + ExpandedNodeId.ServerReference.of(1), + ExpandedNodeId.NamespaceReference.of(localTarget.getNamespaceIndex()), + localTarget.getIdentifier()); + + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"ServerIndexIgnoredAlias"}, + new ExpandedNodeId[] {targetWithServerIndex}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(result)); + + List found = findRootAliases("ServerIndexIgnoredAlias"); + assertEquals(1, found.size()); + assertArrayEquals( + new ExpandedNodeId[] {localTarget.expanded()}, found.get(0).getReferencedNodes()); + } + + // WHY: Part 17 §9.3 — aliases organized under TagVariables must target Variables; a Method + // Node target violates the NodeClass constraint, and the violation is an entry-level failure + // (Bad_InvalidArgument), not a call-level one. + @Test + void tagVariablesEntryTargetingNonVariableFailsWithBadInvalidArgument() throws UaException { + CallMethodResult result = + callAddAliases( + NodeIds.TagVariables, + tagVariablesAddMethodId(), + new String[] {"MethodTagWireAlias"}, + new ExpandedNodeId[] {newNodeId("sqrt(x)").expanded()}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals( + new StatusCode[] {StatusCode.of(StatusCodes.Bad_InvalidArgument)}, errorCodes(result)); + + assertTrue( + aliasManager.findAlias(NodeIds.TagVariables, "MethodTagWireAlias", null).isEmpty()); + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class AddCallLevelValidation { + + private AliasManager aliasManager; + + @BeforeAll + void startManager() { + aliasManager = new AliasManager(server, grantedConfig(new RecordingVersionStore(server))); + aliasManager.startup(); + } + + @AfterAll + void shutdownManager() { + aliasManager.shutdown(); + } + + // WHY: Part 17 §6.3.4 requires AliasNames and TargetNodes to be parallel arrays; a length + // mismatch invalidates the whole call with Bad_InvalidArgument, and no entry may be applied. + @Test + void aliasNamesAndTargetNodesOfDifferentLengthFailTheCall() throws UaException { + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"MismatchAliasA", "MismatchAliasB"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode()); + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "MismatchAliasA", null).isEmpty()); + } + + // WHY: Part 17 §6.3.4 Table 11 defines Bad_InvalidArgument for a call where "the size of + // the arrays for all arguments except TargetServers is not the same or if all arrays are + // empty" — a zero-entry Add call is a call-level failure, not a vacuous success. + @Test + void addWithAllArraysEmptyFailsTheCallWithBadInvalidArgument() throws UaException { + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[0], + new ExpandedNodeId[0], + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode()); + } + + // WHY: Part 17 §6.3.4 — a null or empty TargetServers array means all targets are local, but + // a NON-empty one must be parallel to the other arrays; any other length invalidates the + // whole call with Bad_InvalidArgument. + @Test + void nonEmptyTargetServersOfDifferentLengthFailTheCall() throws UaException { + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"ServerMismatchAliasA", "ServerMismatchAliasB"}, + new ExpandedNodeId[] { + newNodeId("TestInt32").expanded(), newNodeId("TestAnalogValue").expanded() + }, + new String[] {"urn:test:remote-server"}, + NodeId.NULL_VALUE); + + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode()); + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "ServerMismatchAliasA", null).isEmpty()); + } + + // WHY: Part 17 §6.3.4 defaults a null TargetReferenceType to AliasFor, and §6.3.1 requires + // every alias to reference its targets with AliasFor or a subtype; the parameter applies to + // the whole call, so a ReferenceType outside the hierarchy (Organizes) fails the call with + // Bad_InvalidArgument rather than any individual entry. + @Test + void targetReferenceTypeOutsideAliasForHierarchyFailsTheCall() throws UaException { + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + new String[] {"BadRefTypeAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeIds.Organizes); + + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode()); + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "BadRefTypeAlias", null).isEmpty()); + } + + // WHY: Part 17 §6.3.4 names no code for oversized arrays, but Part 4 §7.38.2 defines + // Bad_TooManyOperations as "the request specified too many operations" — exactly the + // condition when the entry count exceeds the configured maxOperationsPerCall — so the whole + // call fails with it before any entry is processed. + @Test + void addWithMoreEntriesThanMaxOperationsPerCallFailsTheCall() throws UaException { + int entryCount = MAX_OPERATIONS_PER_CALL + 1; + var aliasNames = new String[entryCount]; + var targetNodes = new ExpandedNodeId[entryCount]; + for (int i = 0; i < entryCount; i++) { + aliasNames[i] = "OversizedAddAlias" + i; + targetNodes[i] = newNodeId("TestInt32").expanded(); + } + + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + aliasNames, + targetNodes, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.of(StatusCodes.Bad_TooManyOperations), result.getStatusCode()); + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "OversizedAddAlias0", null).isEmpty()); + } + + // WHY: the same Part 4 §7.38.2 operations limit applies to DeleteAliasesFromCategory; an + // oversized AliasNames array fails the whole call with Bad_TooManyOperations before any + // entry is evaluated. + @Test + void deleteWithMoreEntriesThanMaxOperationsPerCallFailsTheCall() throws UaException { + int entryCount = MAX_OPERATIONS_PER_CALL + 1; + var aliasNames = new String[entryCount]; + for (int i = 0; i < entryCount; i++) { + aliasNames[i] = "OversizedDeleteAlias" + i; + } + + CallMethodResult result = + callDeleteAliases(NodeIds.Aliases, aliasesDeleteMethodId(), aliasNames, null); + + assertEquals(StatusCode.of(StatusCodes.Bad_TooManyOperations), result.getStatusCode()); + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class DeleteAliasesFromCategoryCalls { + + private RecordingVersionStore versionStore; + private AliasManager aliasManager; + + @BeforeAll + void startManager() { + versionStore = new RecordingVersionStore(server); + aliasManager = new AliasManager(server, grantedConfig(versionStore)); + aliasManager.startup(); + } + + @AfterAll + void shutdownManager() { + aliasManager.shutdown(); + } + + // WHY: Part 17 §6.3.5 — TargetNodes entries "further restrict what is deleted": deleting one + // explicit target removes only that association, and the alias survives with its remaining + // target still visible to FindAlias. + @Test + void explicitTargetEntryRemovesOnlyThatAssociation() throws UaException { + addRootAliasOverWire( + "PartialWireAlias", newNodeId("TestInt32"), newNodeId("TestAnalogValue")); + + CallMethodResult result = + callDeleteAliases( + NodeIds.Aliases, + aliasesDeleteMethodId(), + new String[] {"PartialWireAlias"}, + new ExpandedNodeId[] {newNodeId("TestAnalogValue").expanded()}); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(result)); + + List found = findRootAliases("PartialWireAlias"); + assertEquals(1, found.size()); + assertArrayEquals( + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + found.get(0).getReferencedNodes()); + } + + // WHY: Part 17 §6.3.1 — every alias shall have at least one AliasFor Reference, so removing + // the last target over the wire must delete the alias Object from EVERY organizing category, + // not just the one the Method was called on, and §6.3.1's LastChange contract requires every + // one of those categories to be bumped. + @Test + void removingLastTargetDeletesAliasFromEveryOrganizingCategory() throws UaException { + addRootAliasOverWire("SharedWireAlias", newNodeId("TestInt32")); + + AliasCategory catB = + aliasManager.addCategory( + new AliasCategoryConfig( + newNodeId("DeleteWireCatB"), + NodeIds.Aliases, + newQualifiedName("DeleteWireCatB"), + testNodeManager, + name -> newNodeId("DeleteWireCatB/" + name), + true, + false, + false)); + try { + // Organize the alias by a second category, the multi-parent arrangement §6.3.1 allows. + NodeId aliasNodeId = + requireNonNull( + findOrganizedAliasNodeId(NodeIds.Aliases, "SharedWireAlias"), + "alias Node not found under Aliases"); + UaNode aliasNode = + server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElseThrow(); + aliasNode.addReference( + new Reference( + aliasNodeId, + NodeIds.Organizes, + catB.nodeId().expanded(), + Reference.Direction.INVERSE)); + + UInteger rootBefore = requireLastChange(server, NodeIds.Aliases); + UInteger catBBefore = requireLastChange(server, catB.nodeId()); + + CallMethodResult result = + callDeleteAliases( + NodeIds.Aliases, + aliasesDeleteMethodId(), + new String[] {"SharedWireAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(result)); + + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isEmpty()); + assertTrue(aliasManager.findAlias(catB.nodeId(), "SharedWireAlias", null).isEmpty()); + + assertStrictlyGreater(catBBefore, readLastChange(server, catB.nodeId())); + assertStrictlyGreater(rootBefore, readLastChange(server, NodeIds.Aliases)); + } finally { + aliasManager.removeCategory(catB.nodeId()); + } + } + + // WHY: Part 17 §6.3.5 defines Bad_NotFound as "The AliasName was not located": a name the + // category does not contain fails its own entry, and since nothing changed, LastChange is + // neither bumped nor persisted. + @Test + void unknownAliasNameEntryFailsWithBadNotFound() throws UaException { + long savesBefore = versionStore.savesFor(NodeIds.Aliases); + + CallMethodResult result = + callDeleteAliases( + NodeIds.Aliases, aliasesDeleteMethodId(), new String[] {"NoSuchWireAlias"}, null); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals( + new StatusCode[] {StatusCode.of(StatusCodes.Bad_NotFound)}, errorCodes(result)); + assertEquals(savesBefore, versionStore.savesFor(NodeIds.Aliases)); + } + + // WHY: Part 17 §6.3.5 — each entry succeeds or fails on its own, so a Bad_NotFound entry + // must not prevent the sibling entry in the SAME call from deleting its alias. A null + // TargetNodes ARRAY is accepted as "no restriction for any entry" — §6.3.5 only defines the + // meaning of null entries, and the reading mirrors §6.3.4's null-or-empty TargetServers. + @Test + void failedEntryDoesNotAffectSiblingEntryInSameDeleteCall() throws UaException { + addRootAliasOverWire("DeleteSurvivorAlias", newNodeId("TestInt32")); + + CallMethodResult result = + callDeleteAliases( + NodeIds.Aliases, + aliasesDeleteMethodId(), + new String[] {"NoSuchWireAlias", "DeleteSurvivorAlias"}, + null); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals( + new StatusCode[] {StatusCode.of(StatusCodes.Bad_NotFound), StatusCode.GOOD}, + errorCodes(result)); + + assertTrue(aliasManager.findAlias(NodeIds.Aliases, "DeleteSurvivorAlias", null).isEmpty()); + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class ApplicationCategories { + + private AliasManager aliasManager; + + @BeforeAll + void startManager() { + aliasManager = new AliasManager(server, grantedConfig(new RecordingVersionStore(server))); + aliasManager.startup(); + } + + @AfterAll + void shutdownManager() { + aliasManager.shutdown(); + } + + // WHY: AliasCategoryConfig.configurationEnabled materializes and binds the category's own + // AddAliasesToCategory/DeleteAliasesFromCategory instances (Optional members per Part 17 + // §6.3.1), so a client granted mutation can manage the category's aliases entirely over the + // wire, with the §6.3.4/§6.3.5 per-entry contract. + @Test + void categoryWithConfigurationEnabledGetsCallableMutationMethods() throws UaException { + AliasCategory category = + aliasManager.addCategory( + new AliasCategoryConfig( + newNodeId("ConfiguredWireCategory"), + NodeIds.Aliases, + newQualifiedName("ConfiguredWireCategory"), + testNodeManager, + name -> newNodeId("ConfiguredWireCategory/" + name), + false, + false, + true)); + try { + assertTrue(category.configurationEnabled()); + + NodeId addMethodId = + requireNonNull( + findComponentMethodId(category.nodeId(), "AddAliasesToCategory"), + "AddAliasesToCategory Method not found"); + NodeId deleteMethodId = + requireNonNull( + findComponentMethodId(category.nodeId(), "DeleteAliasesFromCategory"), + "DeleteAliasesFromCategory Method not found"); + + CallMethodResult addResult = + callAddAliases( + category.nodeId(), + addMethodId, + new String[] {"CategoryWireAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, addResult.getStatusCode()); + assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(addResult)); + assertEquals( + 1, aliasManager.findAlias(category.nodeId(), "CategoryWireAlias", null).size()); + + CallMethodResult deleteResult = + callDeleteAliases( + category.nodeId(), deleteMethodId, new String[] {"CategoryWireAlias"}, null); + + assertEquals(StatusCode.GOOD, deleteResult.getStatusCode()); + assertArrayEquals(new StatusCode[] {StatusCode.GOOD}, errorCodes(deleteResult)); + assertTrue(aliasManager.findAlias(category.nodeId(), "CategoryWireAlias", null).isEmpty()); + } finally { + aliasManager.removeCategory(category.nodeId()); + } + } + + // WHY: entries execute application-supplied code (the category's aliasNodeIdFactory and its + // NodeManager), which can throw unchecked; Part 17 §6.3.4 requires per-entry independence + // and §6.3.1 requires LastChange to reflect what changed, so a RuntimeException from one + // entry must map to that entry's Bad_InternalError while the sibling entry still creates + // its alias and the category's LastChange bumps for the applied entry. + @Test + void runtimeExceptionFromAliasNodeIdFactoryFailsOnlyItsEntry() throws UaException { + AliasCategory category = + aliasManager.addCategory( + new AliasCategoryConfig( + newNodeId("ThrowingFactoryCategory"), + NodeIds.Aliases, + newQualifiedName("ThrowingFactoryCategory"), + testNodeManager, + name -> { + if ("ThrowingFactoryAlias".equals(name)) { + throw new IllegalStateException("factory failure for " + name); + } + return newNodeId("ThrowingFactoryCategory/" + name); + }, + true, + false, + true)); + try { + NodeId addMethodId = + requireNonNull( + findComponentMethodId(category.nodeId(), "AddAliasesToCategory"), + "AddAliasesToCategory Method not found"); + + UInteger lastChangeBefore = requireLastChange(server, category.nodeId()); + + CallMethodResult result = + callAddAliases( + category.nodeId(), + addMethodId, + new String[] {"FactorySurvivorAlias", "ThrowingFactoryAlias"}, + new ExpandedNodeId[] { + newNodeId("TestInt32").expanded(), newNodeId("TestAnalogValue").expanded() + }, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals( + new StatusCode[] {StatusCode.GOOD, StatusCode.of(StatusCodes.Bad_InternalError)}, + errorCodes(result)); + + assertEquals( + 1, aliasManager.findAlias(category.nodeId(), "FactorySurvivorAlias", null).size()); + assertTrue( + aliasManager.findAlias(category.nodeId(), "ThrowingFactoryAlias", null).isEmpty()); + + assertStrictlyGreater(lastChangeBefore, readLastChange(server, category.nodeId())); + } finally { + aliasManager.removeCategory(category.nodeId()); + } + } + + // WHY: a failure after the alias Node was added to the NodeManager but before it was fully + // wired must not leave the Node behind — untyped, unlinked, or targetless it violates the + // Part 17 §6.3.1 alias model and would be visible to searches forever. The entry reports + // Bad_InternalError (§6.3.4 per-entry contract for unchecked application failures) and the + // best-effort cleanup deletes the Node again. + @Test + void nodeManagerFailureAfterNodeAddFailsEntryAndRemovesPartiallyCreatedAlias() + throws UaException { + // The category's Nodes must live in a routable AddressSpace for the wire Call to reach + // them, so the throwing NodeManager is hosted by a registered fragment. + var fragment = new AliasForRejectingFragment(server); + fragment.startup(); + try { + AliasCategory category = + aliasManager.addCategory( + new AliasCategoryConfig( + newNodeId("RejectingCategory"), + NodeIds.Aliases, + newQualifiedName("RejectingCategory"), + fragment.getNodeManager(), + name -> newNodeId("RejectingCategory/" + name), + false, + false, + true)); + try { + NodeId addMethodId = + requireNonNull( + findComponentMethodId(category.nodeId(), "AddAliasesToCategory"), + "AddAliasesToCategory Method not found"); + + CallMethodResult result = + callAddAliases( + category.nodeId(), + addMethodId, + new String[] {"HalfWiredAlias"}, + new ExpandedNodeId[] {newNodeId("TestInt32").expanded()}, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + assertArrayEquals( + new StatusCode[] {StatusCode.of(StatusCodes.Bad_InternalError)}, errorCodes(result)); + + // Cleanup deleted the partially created Node: it is absent from the AddressSpace and + // the category organizes nothing by that name. + NodeId aliasNodeId = newNodeId("RejectingCategory/HalfWiredAlias"); + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isEmpty()); + assertNull(findOrganizedAliasNodeId(category.nodeId(), "HalfWiredAlias")); + } finally { + aliasManager.removeCategory(category.nodeId()); + } + } finally { + fragment.shutdown(); + } + } + + // WHY: the mutation surface is per category — with configurationEnabled false the category + // gets its mandatory FindAlias but no AddAliasesToCategory or DeleteAliasesFromCategory + // instance at all (Optional per Part 17 §6.3.1): no Method Node, no callable surface. + @Test + void categoryWithConfigurationDisabledHasNoMutationMethods() throws UaException { + AliasCategory category = + aliasManager.addCategory( + new AliasCategoryConfig( + newNodeId("UnconfiguredWireCategory"), + NodeIds.Aliases, + newQualifiedName("UnconfiguredWireCategory"), + testNodeManager, + name -> newNodeId("UnconfiguredWireCategory/" + name), + false, + false, + false)); + try { + assertFalse(category.configurationEnabled()); + + assertNotNull(findComponentMethodId(category.nodeId(), "FindAlias")); + assertNull(findComponentMethodId(category.nodeId(), "AddAliasesToCategory")); + assertNull(findComponentMethodId(category.nodeId(), "DeleteAliasesFromCategory")); + } finally { + aliasManager.removeCategory(category.nodeId()); + } + } + } + + /** + * A manager config with the mutation Methods materialized on the standard categories, a policy + * granting mutation to every session, and a small operations-per-call limit so oversized-array + * behavior is testable. + */ + private AliasManagerConfig grantedConfig(AliasVersionStore versionStore) { + return AliasManagerConfig.builder() + .configurationEnabled(true) + .authorizationPolicy(ALLOW_FIND_AND_MUTATE) + .versionStore(versionStore) + .limits(new AliasLimits(1000, 512, MAX_OPERATIONS_PER_CALL)) + .nodeNamespaceIndex(testNamespace.getNamespaceIndex()) + .build(); + } + + /** The NodeId the manager allocates for the {@code AddAliasesToCategory} Node on Aliases. */ + private NodeId aliasesAddMethodId() { + return newNodeId("Aliases/AddAliasesToCategory"); + } + + /** The NodeId the manager allocates for the {@code DeleteAliasesFromCategory} Node on Aliases. */ + private NodeId aliasesDeleteMethodId() { + return newNodeId("Aliases/DeleteAliasesFromCategory"); + } + + /** The NodeId the manager allocates for the {@code AddAliasesToCategory} Node on TagVariables. */ + private NodeId tagVariablesAddMethodId() { + return newNodeId("TagVariables/AddAliasesToCategory"); + } + + /** + * Add {@code aliasName} to the root {@code Aliases} category over the wire, one Add entry per + * target, asserting every entry reports {@code Good}. + */ + private void addRootAliasOverWire(String aliasName, NodeId... targets) throws UaException { + var aliasNames = new String[targets.length]; + var targetNodes = new ExpandedNodeId[targets.length]; + for (int i = 0; i < targets.length; i++) { + aliasNames[i] = aliasName; + targetNodes[i] = targets[i].expanded(); + } + + CallMethodResult result = + callAddAliases( + NodeIds.Aliases, + aliasesAddMethodId(), + aliasNames, + targetNodes, + new String[0], + NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + for (StatusCode statusCode : errorCodes(result)) { + assertEquals(StatusCode.GOOD, statusCode); + } + } + + /** Call {@code AddAliasesToCategory} through the client. */ + private CallMethodResult callAddAliases( + NodeId objectId, + NodeId methodId, + String[] aliasNames, + ExpandedNodeId[] targetNodes, + String[] targetServers, + NodeId targetReferenceType) + throws UaException { + + return callMethod( + objectId, + methodId, + new Variant[] { + new Variant(aliasNames), + new Variant(targetNodes), + new Variant(targetServers), + new Variant(targetReferenceType) + }); + } + + /** Call {@code DeleteAliasesFromCategory} through the client. */ + private CallMethodResult callDeleteAliases( + NodeId objectId, + NodeId methodId, + String[] aliasNames, + ExpandedNodeId @Nullable [] targetNodes) + throws UaException { + + return callMethod( + objectId, methodId, new Variant[] {new Variant(aliasNames), new Variant(targetNodes)}); + } + + private CallMethodResult callMethod(NodeId objectId, NodeId methodId, Variant[] inputs) + throws UaException { + + CallResponse response = client.call(List.of(new CallMethodRequest(objectId, methodId, inputs))); + + return requireNonNull(response.getResults())[0]; + } + + /** The per-entry {@code ErrorCodes} output of a mutation call that reached its handler. */ + private StatusCode[] errorCodes(CallMethodResult result) { + Variant[] outputs = requireNonNull(result.getOutputArguments()); + + return (StatusCode[]) requireNonNull(outputs[0].value()); + } + + /** + * Call {@code FindAlias} on the root {@code Aliases} Object through the client and decode the + * result entries. + */ + private List findRootAliases(String pattern) throws UaException { + CallMethodResult result = + callMethod( + NodeIds.Aliases, + NodeIds.Aliases_FindAlias, + new Variant[] {new Variant(pattern), new Variant(NodeId.NULL_VALUE)}); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + + Variant[] outputs = requireNonNull(result.getOutputArguments()); + ExtensionObject[] xos = (ExtensionObject[]) requireNonNull(outputs[0].value()); + + var entries = new ArrayList(); + for (ExtensionObject xo : xos) { + entries.add((AliasNameDataType) xo.decode(client.getStaticEncodingContext())); + } + return entries; + } + + /** + * The NodeId of the Method component of {@code objectId} with ns=0 BrowseName {@code methodName}, + * or null if the Object has no such component. + */ + private @Nullable NodeId findComponentMethodId(NodeId objectId, String methodName) { + List references = + server + .getAddressSpaceManager() + .getManagedReferences(objectId, Reference.HAS_COMPONENT_PREDICATE); + + for (Reference reference : references) { + UaNode node = + reference + .getTargetNodeId() + .toNodeId(server.getNamespaceTable()) + .flatMap(id -> server.getAddressSpaceManager().getManagedNode(id)) + .orElse(null); + + if (node instanceof UaMethodNode + && new QualifiedName(0, methodName).equals(node.getBrowseName())) { + return node.getNodeId(); + } + } + return null; + } + + /** + * The NodeId of the alias Object named {@code aliasName} directly organized by {@code categoryId} + * (BrowseName text match, namespace ignored), or null if there is none. + */ + private @Nullable NodeId findOrganizedAliasNodeId(NodeId categoryId, String aliasName) { + List references = + server + .getAddressSpaceManager() + .getManagedReferences(categoryId, Reference.ORGANIZES_PREDICATE); + + for (Reference reference : references) { + UaNode node = + reference + .getTargetNodeId() + .toNodeId(server.getNamespaceTable()) + .flatMap(id -> server.getAddressSpaceManager().getManagedNode(id)) + .orElse(null); + + if (node != null && aliasName.equals(node.getBrowseName().name())) { + return node.getNodeId(); + } + } + return null; + } + + /** + * A registered AddressSpace fragment whose {@link UaNodeManager} rejects {@code AliasFor} + * Reference adds with a RuntimeException — the application-NodeManager failure mode that can + * strike after an alias Node was added but before its target Reference was. Node and Reference + * removal stay permitted so the manager's best-effort cleanup can run. + * + *

Registers first in the composite (mirroring the manager's own fragment) so Call requests for + * the Nodes it hosts route here instead of to the TestNamespace, whose filter matches the whole + * test namespace index but whose NodeManager does not contain them. + */ + private static final class AliasForRejectingFragment + extends ManagedAddressSpaceFragmentWithLifecycle { + + private final AddressSpaceFilter filter = + SimpleAddressSpaceFilter.create(getNodeManager()::containsNode); + + AliasForRejectingFragment(OpcUaServer server) { + super( + server, + new UaNodeManager() { + @Override + public synchronized void addReferences( + Reference reference, NamespaceTable namespaceTable) { + if (NodeIds.AliasFor.equals(reference.getReferenceTypeId())) { + throw new IllegalStateException("simulated NodeManager failure: " + reference); + } + super.addReferences(reference, namespaceTable); + } + }); + } + + @Override + public AddressSpaceFilter getFilter() { + return filter; + } + + @Override + protected void registerWithComposite(AddressSpaceComposite composite) { + composite.registerFirst(this); + } + + @Override + public void onDataItemsCreated(List dataItems) {} + + @Override + public void onDataItemsModified(List dataItems) {} + + @Override + public void onDataItemsDeleted(List dataItems) {} + + @Override + public void onMonitoringModeChanged(List monitoredItems) {} + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasFindTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasFindTest.java new file mode 100644 index 0000000000..8d90271748 --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasFindTest.java @@ -0,0 +1,671 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.test.aliases; + +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.eclipse.milo.opcua.sdk.core.Reference; +import org.eclipse.milo.opcua.sdk.server.UaNodeManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasLimits; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameTypeNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext; +import org.eclipse.milo.opcua.sdk.server.nodes.UaReferenceTypeNode; +import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest; +import org.eclipse.milo.opcua.sdk.test.TestNamespace; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject; +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult; +import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * FindAlias/FindAliasVerbose search semantics (Part 17 §6.3.2/§6.3.3) exercised against a real + * server, through both the {@link AliasManager} programmatic API and over-the-wire Call requests. + * + *

All fixture categories are built once per class under the standard {@code Aliases} Object; + * every search is scoped to its own category subtree so the per-category fixtures stay independent + * of each other (over-the-wire searches from the {@code Aliases} root use literal patterns that + * match exactly one fixture alias). + */ +// Fields below are assigned in @BeforeAll, which the nullability inspection does not model. +@SuppressWarnings("NotNullFieldNotInitialized") +public class AliasFindTest extends AbstractClientServerTest { + + private static final int MAX_RESULTS = 10; + private static final int MAX_PATTERN_LENGTH = 64; + + private AliasManager aliasManager; + + private UaNodeContext nodeContext; + private UaNodeManager nodeManager; + + /** A ReferenceType registered as a subtype of {@code AliasFor} for filter tests. */ + private NodeId customRefTypeId; + + private NodeId testInt32Id; + + private NodeId patternCatId; + private NodeId wireCatId; + private NodeId recursiveParentId; + private NodeId recursiveChildId; + private NodeId cycleAId; + private NodeId dedupParentId; + private NodeId dedupChildId; + private NodeId filterCatId; + private NodeId orderCatId; + private NodeId nsCatId; + private NodeId bulkCatId; + private NodeId remoteCatId; + + /** The remote target of the mixed local/remote alias in {@link #remoteCatId}. */ + private ExpandedNodeId remoteTargetId; + + @Override + protected void configureTestNamespace(TestNamespace namespace) { + namespace.configure( + (context, uaNodeManager) -> { + nodeContext = context; + nodeManager = uaNodeManager; + + // The standard model ships no concrete subtype of AliasFor, so the subtype-filter + // behavior can only be exercised against a custom ReferenceType. + customRefTypeId = newNodeId("HasTestAliasTarget"); + + var refTypeNode = + new UaReferenceTypeNode( + context, + customRefTypeId, + newQualifiedName("HasTestAliasTarget"), + LocalizedText.english("HasTestAliasTarget"), + LocalizedText.NULL_VALUE, + UInteger.valueOf(0), + UInteger.valueOf(0), + false, + false, + LocalizedText.english("TestAliasTargetOf")); + + uaNodeManager.addNode(refTypeNode); + + refTypeNode.addReference( + new Reference( + customRefTypeId, + NodeIds.HasSubtype, + NodeIds.AliasFor.expanded(), + Reference.Direction.INVERSE)); + }); + + // The ReferenceTypeTree is built lazily and cached; rebuild it so the custom subtype is + // visible to the search engine's filter validation and subtype checks. + server.updateReferenceTypeTree(); + } + + @BeforeAll + void startAliasManagerAndBuildFixtures() throws Exception { + aliasManager = + new AliasManager( + server, + AliasManagerConfig.builder() + .limits(new AliasLimits(MAX_RESULTS, MAX_PATTERN_LENGTH, 10)) + .findAliasVerboseEnabled(true) + .nodeNamespaceIndex(testNamespace.getNamespaceIndex()) + .build()); + + aliasManager.startup(); + + testInt32Id = newNodeId("TestInt32"); + + // Pattern semantics: four aliases whose names exercise wildcard, class, and case matching. + patternCatId = addCategory("PatternCat", NodeIds.Aliases); + for (String name : List.of("Apple", "Apricot", "Banana", "apple")) { + aliasManager.addAlias(patternCatId, name, aliasForTargets(testInt32Id)); + } + + // Over-the-wire cases search from the Aliases root with the literal pattern "WireAlias". + wireCatId = addCategory("WireCat", NodeIds.Aliases); + aliasManager.addAlias(wireCatId, "WireAlias", aliasForTargets(testInt32Id)); + + // Recursive scope: an alias organized only by a subcategory of the searched category. + recursiveParentId = addCategory("RecursiveParent", NodeIds.Aliases); + recursiveChildId = addCategory("RecursiveChild", recursiveParentId); + aliasManager.addAlias(recursiveChildId, "DeepAlias", aliasForTargets(testInt32Id)); + + // Cycle: CycleA organizes CycleB (via addCategory) and a raw Organizes Reference makes + // CycleB organize CycleA right back. The manager API cannot create this, so it is wired + // with a raw Reference after the alias is added. + cycleAId = addCategory("CycleA", NodeIds.Aliases); + NodeId cycleBId = addCategory("CycleB", cycleAId); + aliasManager.addAlias(cycleBId, "CycleAlias", aliasForTargets(testInt32Id)); + UaNode cycleBNode = getNode(cycleBId); + cycleBNode.addReference( + new Reference( + cycleBId, NodeIds.Organizes, cycleAId.expanded(), Reference.Direction.FORWARD)); + + // Dedup + verbose category selection: one alias organized by both a parent category and its + // subcategory, so two traversal paths reach it from the parent. + dedupParentId = addCategory("DedupParent", NodeIds.Aliases); + dedupChildId = addCategory("DedupChild", dedupParentId); + NodeId sharedAliasId = + aliasManager.addAlias(dedupChildId, "SharedAlias", aliasForTargets(testInt32Id)); + UaNode sharedAliasNode = getNode(sharedAliasId); + sharedAliasNode.addReference( + new Reference( + sharedAliasId, + NodeIds.Organizes, + dedupParentId.expanded(), + Reference.Direction.INVERSE)); + + // ReferenceType filtering: one alias with an AliasFor target and a custom-subtype target. + filterCatId = addCategory("FilterCat", NodeIds.Aliases); + aliasManager.addAlias( + filterCatId, + "FilteredAlias", + List.of( + new AliasTarget(testInt32Id.expanded(), null, NodeIds.AliasFor), + new AliasTarget(NodeIds.Server.expanded(), null, customRefTypeId))); + + // Ordering: two identically named aliases with different NodeIds plus a third name. Raw + // Nodes because addAlias treats a same-named alias as the same alias and extends it. + orderCatId = addCategory("OrderCat", NodeIds.Aliases); + addRawAlias(orderCatId, newNodeId("OrderCat/a1"), newQualifiedName("OrderA")); + addRawAlias(orderCatId, newNodeId("OrderCat/a2"), new QualifiedName(0, "OrderA")); + addRawAlias(orderCatId, newNodeId("OrderCat/b1"), newQualifiedName("OrderB")); + + // BrowseName namespace: same name text in two different BrowseName namespaces. + nsCatId = addCategory("NsCat", NodeIds.Aliases); + aliasManager.addAlias(nsCatId, "NsAlias", aliasForTargets(testInt32Id)); + addRawAlias(nsCatId, newNodeId("NsCat/NsAlias-ns0"), new QualifiedName(0, "NsAlias")); + + // Verbose ServerUris: an alias with a local target plus a remote AliasFor Reference whose + // target ExpandedNodeId carries its Server URI directly (no ServerTable entry needed). The + // manager rejects remote targets (Bad_NotSupported), so the Reference is wired raw. + remoteCatId = addCategory("RemoteCat", NodeIds.Aliases); + NodeId remoteAliasId = + aliasManager.addAlias(remoteCatId, "RemoteMixAlias", aliasForTargets(testInt32Id)); + remoteTargetId = + new ExpandedNodeId( + ExpandedNodeId.ServerReference.of("urn:remote:server"), + ExpandedNodeId.NamespaceReference.of(testInt32Id.getNamespaceIndex()), + "RemoteCat/RemoteTarget"); + getNode(remoteAliasId) + .addReference( + new Reference( + remoteAliasId, NodeIds.AliasFor, remoteTargetId, Reference.Direction.FORWARD)); + + // Result cap: one more matching alias than the configured maxResults. + bulkCatId = addCategory("BulkCat", NodeIds.Aliases); + for (int i = 0; i <= MAX_RESULTS; i++) { + aliasManager.addAlias(bulkCatId, String.format("Bulk%02d", i), aliasForTargets(testInt32Id)); + } + } + + @AfterAll + void shutdownAliasManager() { + aliasManager.shutdown(); + } + + @Nested + class PatternSemantics { + + // Part 17 §6.3.2 defines the search string as a Part 4 §7.7.3 (Table 120) Like pattern: + // '%' matches any run, '_' exactly one character, '[...]' a character list/range, + // '[^...]' its negation, and everything else matches literally. + @ParameterizedTest + @MethodSource("patternCases") + void patternMatchingFollowsPart4LikeGrammar(String pattern, List expectedNames) + throws UaException { + + assertEquals(expectedNames, findNames(patternCatId, pattern)); + } + + static Stream patternCases() { + return Stream.of( + Arguments.of("Apple", List.of("Apple")), + Arguments.of("Grape", List.of()), + Arguments.of("Ap%", List.of("Apple", "Apricot")), + Arguments.of("%", List.of("Apple", "Apricot", "Banana", "apple")), + Arguments.of("Appl_", List.of("Apple")), + Arguments.of("_pple", List.of("Apple", "apple")), + Arguments.of("[AB]%", List.of("Apple", "Apricot", "Banana")), + Arguments.of("[A-C]%", List.of("Apple", "Apricot", "Banana")), + Arguments.of("[^A]%", List.of("Banana", "apple"))); + } + + // Part 4 §7.7.3 Like matching is case-sensitive; the alias design binds this with + // no Unicode folding, so "apple" and "Apple" are distinct names. + @Test + void matchingIsCaseSensitive() throws UaException { + assertEquals(List.of("apple"), findNames(patternCatId, "apple")); + assertEquals(List.of(), findNames(patternCatId, "APPLE")); + } + } + + @Nested + class InvalidArguments { + + // Part 17 §6.3.2 maps an invalid search string to Bad_InvalidArgument; a malformed + // pattern must fail the call rather than silently matching nothing. + @ParameterizedTest + @ValueSource(strings = {"abc\\", "[]", "[abc", "[z-a]"}) + void malformedPatternFailsWithBadInvalidArgument(String pattern) { + UaException e = + assertThrows( + UaException.class, () -> aliasManager.findAlias(patternCatId, pattern, null)); + + assertEquals(StatusCodes.Bad_InvalidArgument, e.getStatusCode().value()); + } + + // The configured maxPatternLength caps pattern cost before parsing; an oversized pattern + // is an invalid argument, not a truncated search. + @Test + void patternLongerThanMaxPatternLengthFailsWithBadInvalidArgument() { + String pattern = "A".repeat(MAX_PATTERN_LENGTH + 1); + + UaException e = + assertThrows( + UaException.class, () -> aliasManager.findAlias(patternCatId, pattern, null)); + + assertEquals(StatusCodes.Bad_InvalidArgument, e.getStatusCode().value()); + } + + // Binding policy: a ReferenceType filter that is not a known ReferenceType fails the call + // with Bad_InvalidArgument instead of being treated as "matches nothing". + @Test + void unknownReferenceTypeFilterFailsWithBadInvalidArgument() { + NodeId unknown = newNodeId("NoSuchReferenceType"); + + UaException e = + assertThrows(UaException.class, () -> aliasManager.findAlias(filterCatId, "%", unknown)); + + assertEquals(StatusCodes.Bad_InvalidArgument, e.getStatusCode().value()); + } + + // Part 17 defines no paging for FindAlias; when more entries match than the configured + // maxResults the call must fail with Bad_ResponseTooLarge so the Client narrows its pattern. + @Test + void moreMatchesThanMaxResultsFailsWithBadResponseTooLarge() { + UaException e = + assertThrows(UaException.class, () -> aliasManager.findAlias(bulkCatId, "Bulk%", null)); + + assertEquals(StatusCodes.Bad_ResponseTooLarge, e.getStatusCode().value()); + } + } + + @Nested + class OverTheWire { + + // End-to-end proof that the standard Aliases FindAlias Method (bound by the manager) is + // callable by a Client and returns the matching alias with its targets. + @Test + void findAliasCallReturnsMatchingAliasAndTargets() throws UaException { + CallMethodResult result = + call(NodeIds.Aliases, NodeIds.Aliases_FindAlias, "WireAlias", NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + + ExtensionObject[] xos = outputArray(result); + assertEquals(1, xos.length); + + var entry = (AliasNameDataType) xos[0].decode(client.getStaticEncodingContext()); + assertEquals("WireAlias", entry.getAliasName().name()); + assertArrayEquals(new ExpandedNodeId[] {testInt32Id.expanded()}, entry.getReferencedNodes()); + } + + // The FindAliasVerbose Method is materialized by the manager (it is not in the standard + // NodeSet) and hosted in the manager's fragment; this proves it routes and that the verbose + // entry reports the organizing category and, per Part 17 §7.3, no server URI for a local + // target. + @Test + void findAliasVerboseCallReportsCategoryAndNoServerUriForLocalTargets() throws UaException { + NodeId verboseMethodId = newNodeId("Aliases/FindAliasVerbose"); + + CallMethodResult result = + call(NodeIds.Aliases, verboseMethodId, "WireAlias", NodeId.NULL_VALUE); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + + ExtensionObject[] xos = outputArray(result); + assertEquals(1, xos.length); + + var entry = (AliasNameVerboseDataType) xos[0].decode(client.getStaticEncodingContext()); + assertEquals("WireAlias", entry.getAliasName().name()); + assertEquals(wireCatId, entry.getAliasNameCategoryId()); + + // Binary encoding may surface a null array as null or empty; both mean "all local". + String[] serverUris = entry.getServerUris(); + assertTrue( + serverUris == null || serverUris.length == 0, + "local targets must have no server URIs, got: " + Arrays.toString(serverUris)); + } + + // Part 17 §6.3.2: an invalid search string fails the Call with Bad_InvalidArgument in the + // CallMethodResult, observable by a real Client. + @Test + void malformedPatternCallFailsWithBadInvalidArgument() throws UaException { + CallMethodResult result = + call(NodeIds.Aliases, NodeIds.Aliases_FindAlias, "[unclosed", NodeId.NULL_VALUE); + + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode()); + } + + // The maxPatternLength limit must hold on the network path too, before any parsing. + @Test + void oversizedPatternCallFailsWithBadInvalidArgument() throws UaException { + String pattern = "A".repeat(MAX_PATTERN_LENGTH + 1); + + CallMethodResult result = + call(NodeIds.Aliases, NodeIds.Aliases_FindAlias, pattern, NodeId.NULL_VALUE); + + assertEquals(StatusCode.of(StatusCodes.Bad_InvalidArgument), result.getStatusCode()); + } + } + + @Nested + class RecursiveScope { + + // Part 17 §6.3.2: FindAlias searches the category and all of its subcategories, so an alias + // organized only by a subcategory is found from the parent. + @Test + void aliasInSubcategoryIsFoundFromParentCategory() throws UaException { + assertEquals(List.of("DeepAlias"), findNames(recursiveParentId, "DeepAlias")); + assertEquals(List.of("DeepAlias"), findNames(recursiveChildId, "DeepAlias")); + } + + // Binding policy 7: results are deduplicated by alias NodeId, so an alias reachable both + // directly and through a subcategory appears exactly once. + @Test + void aliasReachableThroughTwoPathsAppearsOnce() throws UaException { + List results = aliasManager.findAlias(dedupParentId, "SharedAlias", null); + + assertEquals(1, results.size()); + assertEquals("SharedAlias", results.get(0).getAliasName().name()); + } + + // A cycle in the category graph (possible with raw References) must terminate via + // visited-category tracking instead of hanging the search. + @Test + void categoryCycleDoesNotHangSearch() { + List names = + assertTimeoutPreemptively( + Duration.ofSeconds(10), () -> findNames(cycleAId, "CycleAlias")); + + assertEquals(List.of("CycleAlias"), names); + } + } + + @Nested + class ReferenceTypeFilter { + + // Part 17 §6.3.3: with no ReferenceType filter, targets of AliasFor and all of its + // subtypes are returned. + @Test + void nullFilterReturnsTargetsOfAliasForAndItsSubtypes() throws UaException { + List results = aliasManager.findAlias(filterCatId, "FilteredAlias", null); + + assertEquals(1, results.size()); + assertArrayEquals( + new ExpandedNodeId[] {NodeIds.Server.expanded(), testInt32Id.expanded()}, + results.get(0).getReferencedNodes()); + } + + // Part 17 §6.3.3: "any ReferenceType includes all subtypes", so filtering on AliasFor + // still returns the target referenced by the custom AliasFor subtype. + @Test + void aliasForFilterIncludesSubtypeReferences() throws UaException { + List results = + aliasManager.findAlias(filterCatId, "FilteredAlias", NodeIds.AliasFor); + + assertEquals(1, results.size()); + assertArrayEquals( + new ExpandedNodeId[] {NodeIds.Server.expanded(), testInt32Id.expanded()}, + results.get(0).getReferencedNodes()); + } + + // Filtering on a subtype must exclude targets referenced only by the supertype. + @Test + void subtypeFilterReturnsOnlyTargetsOfThatSubtype() throws UaException { + List results = + aliasManager.findAlias(filterCatId, "FilteredAlias", customRefTypeId); + + assertEquals(1, results.size()); + assertArrayEquals( + new ExpandedNodeId[] {NodeIds.Server.expanded()}, results.get(0).getReferencedNodes()); + } + + // Part 17 §6.3.2/§6.3.3: a valid ReferenceType that no alias Reference satisfies yields an + // empty list, not an error — an alias with no passing targets is omitted entirely. + @Test + void filterOutsideAliasForHierarchyYieldsEmptyResult() throws UaException { + List results = + aliasManager.findAlias(filterCatId, "FilteredAlias", NodeIds.Organizes); + + assertEquals(List.of(), results); + } + } + + @Nested + class Ordering { + + // Binding policy 10: entries are ordered by alias name text, then by alias NodeId, so + // identically named aliases and repeat calls return a deterministic sequence. + @Test + void entriesAreOrderedByNameTextThenNodeId() throws UaException { + List results = aliasManager.findAlias(orderCatId, "Order%", null); + + List names = results.stream().map(AliasNameDataType::getAliasName).toList(); + + // "OrderCat/a1" < "OrderCat/a2" decides the two OrderA entries; name text puts OrderB last. + assertEquals( + List.of( + newQualifiedName("OrderA"), + new QualifiedName(0, "OrderA"), + newQualifiedName("OrderB")), + names); + } + } + + @Nested + class Verbose { + + // Binding policy 4: the verbose aliasNameCategoryId is the organizing category with the + // smallest depth from the searched category, so the same alias reports a different category + // depending on where the search starts. + @Test + void verboseCategoryIsSmallestDepthOrganizingCategoryWithinSearchedSubtree() + throws UaException { + + List fromParent = + aliasManager.findAliasVerbose(dedupParentId, "SharedAlias", null); + assertEquals(1, fromParent.size()); + assertEquals(dedupParentId, fromParent.get(0).getAliasNameCategoryId()); + + List fromChild = + aliasManager.findAliasVerbose(dedupChildId, "SharedAlias", null); + assertEquals(1, fromChild.size()); + assertEquals(dedupChildId, fromChild.get(0).getAliasNameCategoryId()); + } + + // Part 17 §7.3 permits a null server URI for local Nodes; every target this server returns + // is local, so the ServerUris field must be absent. + @Test + void verboseServerUrisAreNullForLocalTargets() throws UaException { + List results = + aliasManager.findAliasVerbose(wireCatId, "WireAlias", null); + + assertEquals(1, results.size()); + assertNull(results.get(0).getServerUris()); + } + + // Part 17 §7.3 — once any target is remote, ServerUris must be present and parallel to + // ReferencedNodes: the remote target's Server URI in its slot and null (permitted for local + // Nodes) in the local slots. The default target ordering puts local targets before remote + // ones, fixing which slot is which. + @Test + void verboseServerUrisParallelReferencedNodesWhenAnyTargetIsRemote() throws UaException { + List results = + aliasManager.findAliasVerbose(remoteCatId, "RemoteMixAlias", null); + + assertEquals(1, results.size()); + AliasNameVerboseDataType entry = results.get(0); + + assertArrayEquals( + new ExpandedNodeId[] {testInt32Id.expanded(), remoteTargetId}, + entry.getReferencedNodes()); + + String[] serverUris = entry.getServerUris(); + assertNotNull(serverUris, "a remote target must make the ServerUris array present"); + assertArrayEquals(new String[] {null, "urn:remote:server"}, serverUris); + } + } + + @Nested + class BrowseNameNamespace { + + // Part 17 §6.2: alias name matching considers only the BrowseName's name text; the + // namespace index is ignored, so identically named aliases in different BrowseName + // namespaces both match a literal pattern. + @Test + void browseNameNamespaceIsIgnoredInMatching() throws UaException { + List results = aliasManager.findAlias(nsCatId, "NsAlias", null); + + List names = results.stream().map(AliasNameDataType::getAliasName).toList(); + + // Same name text sorts by NodeId: "NsCat/NsAlias" < "NsCat/NsAlias-ns0". + assertEquals(List.of(newQualifiedName("NsAlias"), new QualifiedName(0, "NsAlias")), names); + } + } + + private NodeId addCategory(String name, NodeId parentCategoryId) throws UaException { + return aliasManager + .addCategory( + new AliasCategoryConfig( + newNodeId(name), + parentCategoryId, + newQualifiedName(name), + nodeManager, + aliasName -> newNodeId(name + "/" + aliasName), + false, + false, + false)) + .nodeId(); + } + + /** + * Create an alias Node directly, bypassing {@link AliasManager#addAlias}: needed for a second + * alias with the same name text (addAlias would extend the existing one) and for BrowseNames in a + * namespace other than the alias NodeId's. + */ + private void addRawAlias(NodeId categoryId, NodeId aliasNodeId, QualifiedName browseName) { + var aliasNode = + new AliasNameTypeNode( + nodeContext, + aliasNodeId, + browseName, + LocalizedText.english(browseName.name()), + LocalizedText.NULL_VALUE, + UInteger.valueOf(0), + UInteger.valueOf(0), + null, + null, + null); + + nodeManager.addNode(aliasNode); + + aliasNode.addReference( + new Reference( + aliasNodeId, + NodeIds.HasTypeDefinition, + NodeIds.AliasNameType.expanded(), + Reference.Direction.FORWARD)); + + aliasNode.addReference( + new Reference( + aliasNodeId, NodeIds.Organizes, categoryId.expanded(), Reference.Direction.INVERSE)); + + aliasNode.addReference( + new Reference( + aliasNodeId, NodeIds.AliasFor, testInt32Id.expanded(), Reference.Direction.FORWARD)); + } + + private List aliasForTargets(NodeId targetId) { + return List.of(new AliasTarget(targetId.expanded(), null, NodeIds.AliasFor)); + } + + private List findNames(NodeId categoryId, String pattern) throws UaException { + var names = new ArrayList(); + for (var entry : aliasManager.findAlias(categoryId, pattern, null)) { + names.add(requireNonNull(entry.getAliasName().name())); + } + return names; + } + + private UaNode getNode(NodeId nodeId) { + return server + .getAddressSpaceManager() + .getManagedNode(nodeId) + .orElseThrow(() -> new IllegalStateException("node not found: " + nodeId)); + } + + private CallMethodResult call( + NodeId objectId, NodeId methodId, String pattern, NodeId referenceTypeFilter) + throws UaException { + + var request = + new CallMethodRequest( + objectId, + methodId, + new Variant[] {new Variant(pattern), new Variant(referenceTypeFilter)}); + + CallResponse response = client.call(List.of(request)); + + return requireNonNull(response.getResults())[0]; + } + + private ExtensionObject[] outputArray(CallMethodResult result) { + Variant[] outputs = requireNonNull(result.getOutputArguments()); + return (ExtensionObject[]) requireNonNull(outputs[0].value()); + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasManagerLifecycleTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasManagerLifecycleTest.java new file mode 100644 index 0000000000..0120f7348a --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasManagerLifecycleTest.java @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.test.aliases; + +import static java.util.Objects.requireNonNull; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readAttribute; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.requireLastChange; +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.eclipse.milo.opcua.sdk.server.UaNodeManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasVersionStore; +import org.eclipse.milo.opcua.sdk.server.methods.AbstractMethodInvocationHandler; +import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler; +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest; +import org.eclipse.milo.opcua.stack.core.AttributeId; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.types.structured.Argument; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult; +import org.eclipse.milo.opcua.stack.core.types.structured.CallResponse; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Lifecycle integration tests for {@link AliasManager}: the no-manager baseline the standard + * namespace establishes at server startup, the state a manager startup applies, the state its + * shutdown restores, and the no-trace guarantee of a failed startup. + * + *

An {@link AliasManager} is one-shot, so every test constructs a fresh instance and returns the + * shared server to the no-manager baseline before finishing. + */ +public class AliasManagerLifecycleTest extends AbstractClientServerTest { + + /** The standard {@code FindAlias} Method instances the manager binds at startup. */ + private static final List STANDARD_FIND_ALIAS_NODE_IDS = + List.of(NodeIds.Aliases_FindAlias, NodeIds.TagVariables_FindAlias, NodeIds.Topics_FindAlias); + + /** + * The NodeId the manager allocates for the {@code FindAliasVerbose} Method it materializes on the + * standard {@code Aliases} Object, derived from the default Node namespace index (1). + */ + private static final NodeId ALIASES_FIND_ALIAS_VERBOSE_NODE_ID = + new NodeId(1, "Aliases/FindAliasVerbose"); + + static Stream standardFindAliasNodeIds() { + return STANDARD_FIND_ALIAS_NODE_IDS.stream(); + } + + @Nested + class WithoutManager { + + // WHY: the standard NodeSet loads the FindAlias instances executable, but a server without an + // AliasManager has no behavior behind them; OpcUaNamespace must surface alias support as an + // absent feature by clearing both executable flags instead of leaving a callable Method that + // always fails (interop honesty for the Part 17 FindAlias contract). + @ParameterizedTest + @MethodSource( + "org.eclipse.milo.opcua.sdk.test.aliases.AliasManagerLifecycleTest#standardFindAliasNodeIds") + void standardFindAliasNodeIsNotExecutableWithoutManager(NodeId findAliasNodeId) { + UaMethodNode methodNode = serverMethodNode(findAliasNodeId); + + assertFalse(methodNode.isExecutable()); + assertFalse(methodNode.isUserExecutable()); + assertInstanceOf( + MethodInvocationHandler.NotImplementedHandler.class, methodNode.getInvocationHandler()); + } + + // WHY: DefaultAccessController enforces UserExecutable on Call, so the no-manager baseline + // must reject a client Call with Bad_UserAccessDenied at the access-control gate rather than + // reaching the NotImplemented handler and answering Bad_NotImplemented. + @Test + void callingFindAliasWithoutManagerIsDeniedUserAccess() throws UaException { + CallMethodResult result = callFindAlias(NodeIds.Aliases, NodeIds.Aliases_FindAlias); + + assertEquals(StatusCode.of(StatusCodes.Bad_UserAccessDenied), result.getStatusCode()); + } + } + + @Nested + class AfterStartup { + + // WHY: startup must restore both executable flags on every standard FindAlias Node when it + // binds its handlers — UserExecutable is the flag access control enforces on Call, and + // Executable is what clients browse to discover the feature is present. + @Test + void startupRestoresExecutableFlagsOnStandardFindAliasNodes() { + AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build()); + manager.startup(); + try { + for (NodeId findAliasNodeId : STANDARD_FIND_ALIAS_NODE_IDS) { + UaMethodNode methodNode = serverMethodNode(findAliasNodeId); + + assertTrue(methodNode.isExecutable()); + assertTrue(methodNode.isUserExecutable()); + } + } finally { + manager.shutdown(); + } + } + + // WHY: Part 17 §6.3.2 — FindAlias returns the array of matching aliases; a pattern that + // matches nothing yields an empty array in the output argument, not a null value or a Bad + // result. + @Test + void findAliasCallSucceedsWithEmptyResultArrayWhenNothingMatches() throws UaException { + AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build()); + manager.startup(); + try { + CallMethodResult result = callFindAlias(NodeIds.Aliases, NodeIds.Aliases_FindAlias); + + assertEquals(StatusCode.GOOD, result.getStatusCode()); + + Variant[] outputs = requireNonNull(result.getOutputArguments()); + assertEquals(1, outputs.length); + + ExtensionObject[] aliasNodeList = + assertInstanceOf(ExtensionObject[].class, outputs[0].value()); + assertEquals(0, aliasNodeList.length); + } finally { + manager.shutdown(); + } + } + + // WHY: the materialized FindAliasVerbose Node lives in the manager's own AddressSpace + // fragment, not in any application namespace; a client Read of its attributes and argument + // Properties pins that the fragment registration routes services to fragment-hosted Nodes, + // and a client Call pins that Call resolution finds the Method on the ns=0 Aliases Object. + @Test + void materializedFindAliasVerboseIsReadableAndCallableByClient() throws UaException { + AliasManager manager = + new AliasManager( + server, AliasManagerConfig.builder().findAliasVerboseEnabled(true).build()); + manager.startup(); + try { + DataValue browseName = + readAttribute(client, ALIASES_FIND_ALIAS_VERBOSE_NODE_ID, AttributeId.BrowseName); + assertEquals(StatusCode.GOOD, browseName.getStatusCode()); + assertEquals(new QualifiedName(0, "FindAliasVerbose"), browseName.value().value()); + + // The argument Properties are created alongside the Method Node, with NodeIds derived + // from it, and must be readable through the fragment as well. + DataValue inputArguments = + readAttribute( + client, + new NodeId(1, "Aliases/FindAliasVerbose.InputArguments"), + AttributeId.Value); + assertEquals(StatusCode.GOOD, inputArguments.getStatusCode()); + ExtensionObject[] inputArgumentsValue = + assertInstanceOf(ExtensionObject[].class, inputArguments.value().value()); + assertEquals(2, inputArgumentsValue.length); + + DataValue outputArguments = + readAttribute( + client, + new NodeId(1, "Aliases/FindAliasVerbose.OutputArguments"), + AttributeId.Value); + assertEquals(StatusCode.GOOD, outputArguments.getStatusCode()); + ExtensionObject[] outputArgumentsValue = + assertInstanceOf(ExtensionObject[].class, outputArguments.value().value()); + assertEquals(1, outputArgumentsValue.length); + + CallMethodResult result = + callFindAlias(NodeIds.Aliases, ALIASES_FIND_ALIAS_VERBOSE_NODE_ID); + assertEquals(StatusCode.GOOD, result.getStatusCode()); + + Variant[] outputs = requireNonNull(result.getOutputArguments()); + ExtensionObject[] aliasNodeList = + assertInstanceOf(ExtensionObject[].class, outputs[0].value()); + assertEquals(0, aliasNodeList.length); + } finally { + manager.shutdown(); + } + } + } + + @Nested + class AfterShutdown { + + // WHY: shutdown must return the standard FindAlias Nodes to the no-manager baseline — + // handlers unbound and flags cleared — so a client Call is again denied at the + // access-control gate exactly as before the manager existed. + @Test + void shutdownResetsHandlersAndFlagsSoCallIsDeniedAgain() throws UaException { + AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build()); + manager.startup(); + try { + CallMethodResult before = callFindAlias(NodeIds.Aliases, NodeIds.Aliases_FindAlias); + assertEquals(StatusCode.GOOD, before.getStatusCode()); + } finally { + manager.shutdown(); + } + + assertNoManagerBaseline(); + + CallMethodResult after = callFindAlias(NodeIds.Aliases, NodeIds.Aliases_FindAlias); + assertEquals(StatusCode.of(StatusCodes.Bad_UserAccessDenied), after.getStatusCode()); + } + + // WHY: Node hosting rule — Nodes the manager creates in its own fragment leave the + // AddressSpace with the manager, so after shutdown a client Read of the materialized + // FindAliasVerbose Node must fail with Bad_NodeIdUnknown instead of exposing a Method with + // no behavior behind it. + @Test + void shutdownRemovesMaterializedFindAliasVerboseNodes() throws UaException { + AliasManager manager = + new AliasManager( + server, AliasManagerConfig.builder().findAliasVerboseEnabled(true).build()); + manager.startup(); + try { + DataValue whileRunning = + readAttribute(client, ALIASES_FIND_ALIAS_VERBOSE_NODE_ID, AttributeId.BrowseName); + assertEquals(StatusCode.GOOD, whileRunning.getStatusCode()); + } finally { + manager.shutdown(); + } + + DataValue afterShutdown = + readAttribute(client, ALIASES_FIND_ALIAS_VERBOSE_NODE_ID, AttributeId.BrowseName); + assertEquals(StatusCode.of(StatusCodes.Bad_NodeIdUnknown), afterShutdown.getStatusCode()); + } + + // WHY: Node hosting rule — category and alias Nodes created through addCategory live in the + // application-supplied NodeManager, so they must remain in the AddressSpace after the manager + // shuts down; only the manager's behavior (handlers, flags, fragment Nodes) is removed. + @Test + void categoryAndAliasNodesCreatedInApplicationNamespaceSurviveShutdown() throws UaException { + var nodeManagerRef = new AtomicReference<@Nullable UaNodeManager>(); + testNamespace.configure((context, nodeManager) -> nodeManagerRef.set(nodeManager)); + UaNodeManager nodeManager = requireNonNull(nodeManagerRef.get()); + + AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build()); + manager.startup(); + + NodeId categoryId = newNodeId("SurvivingCategory"); + NodeId aliasNodeId = newNodeId("AliasCat/SurvivingAlias"); + try { + try { + var categoryConfig = + new AliasCategoryConfig( + newNodeId("SurvivingCategory"), + NodeIds.Aliases, + newQualifiedName("SurvivingCategory"), + nodeManager, + name -> newNodeId("AliasCat/" + name), + false, + false, + false); + + assertEquals(categoryId, manager.addCategory(categoryConfig).nodeId()); + + NodeId createdAliasNodeId = + manager.addAlias( + categoryId, + "SurvivingAlias", + List.of( + new AliasTarget(newNodeId("TestInt32").expanded(), null, NodeIds.AliasFor))); + assertEquals(aliasNodeId, createdAliasNodeId); + } finally { + manager.shutdown(); + } + + assertTrue(server.getAddressSpaceManager().getManagedNode(categoryId).isPresent()); + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()); + + DataValue categoryBrowseName = readAttribute(client, categoryId, AttributeId.BrowseName); + assertEquals(StatusCode.GOOD, categoryBrowseName.getStatusCode()); + assertEquals(newQualifiedName("SurvivingCategory"), categoryBrowseName.value().value()); + + DataValue aliasBrowseName = readAttribute(client, aliasNodeId, AttributeId.BrowseName); + assertEquals(StatusCode.GOOD, aliasBrowseName.getStatusCode()); + } finally { + // Delete the surviving Nodes so other tests observe an alias-free hierarchy; the alias + // first, so its category linkage is gone before the category (and its children) go. + server.getAddressSpaceManager().getManagedNode(aliasNodeId).ifPresent(UaNode::delete); + server.getAddressSpaceManager().getManagedNode(categoryId).ifPresent(UaNode::delete); + } + } + } + + @Nested + class StartupFailure { + + // WHY: startup validates every standard FindAlias Node for an application-bound handler + // before mutating anything, so a conflict must fail startup with IllegalStateException and + // leave no trace — flags unchanged, no other Node's handler touched, and the conflicting + // handler still in place. + @Test + void handlerConflictFailsStartupAndLeavesNoStateBehind() { + UaMethodNode conflictedNode = serverMethodNode(NodeIds.TagVariables_FindAlias); + + var conflictingHandler = + new AbstractMethodInvocationHandler(conflictedNode) { + @Override + public Argument[] getInputArguments() { + return new Argument[0]; + } + + @Override + public Argument[] getOutputArguments() { + return new Argument[0]; + } + + @Override + protected Variant[] invoke(InvocationContext invocationContext, Variant[] inputValues) { + return new Variant[0]; + } + }; + + conflictedNode.setInvocationHandler(conflictingHandler); + try { + AliasManager manager = new AliasManager(server, AliasManagerConfig.builder().build()); + + assertThrows(IllegalStateException.class, manager::startup); + + // The conflicting handler is untouched and the other standard Nodes are still in the + // unbound baseline state. + assertSame(conflictingHandler, conflictedNode.getInvocationHandler()); + assertFalse(conflictedNode.isExecutable()); + assertFalse(conflictedNode.isUserExecutable()); + + for (NodeId nodeId : List.of(NodeIds.Aliases_FindAlias, NodeIds.Topics_FindAlias)) { + UaMethodNode methodNode = serverMethodNode(nodeId); + + assertInstanceOf( + MethodInvocationHandler.NotImplementedHandler.class, + methodNode.getInvocationHandler()); + assertFalse(methodNode.isExecutable()); + assertFalse(methodNode.isUserExecutable()); + } + } finally { + conflictedNode.setInvocationHandler(MethodInvocationHandler.NOT_IMPLEMENTED); + } + + // A failed startup left nothing behind, so a fresh manager starts up cleanly afterwards. + AliasManager freshManager = new AliasManager(server, AliasManagerConfig.builder().build()); + freshManager.startup(); + try { + assertTrue(serverMethodNode(NodeIds.TagVariables_FindAlias).isUserExecutable()); + } finally { + freshManager.shutdown(); + } + } + + // WHY: Part 17 §6.3.1 requires the root LastChange version to persist across restarts; + // starting with silently reset versions would leave client caches undetectably stale, so an + // unreadable version store must fail startup — and, failing in the mutation phase, must roll + // back to the no-manager baseline. + @Test + void versionStoreLoadFailureFailsStartupAndLeavesNoStateBehind() { + AliasManager manager = + new AliasManager( + server, AliasManagerConfig.builder().versionStore(failingVersionStore()).build()); + + IllegalStateException e = assertThrows(IllegalStateException.class, manager::startup); + assertInstanceOf(UaException.class, e.getCause()); + + assertNoManagerBaseline(); + } + + // WHY: a manager whose startup failed must be safely disposable — the lifecycle lands in + // the stopped state, so isNotRunning() reports the failure and a subsequent shutdown() is a + // silent no-op. Were shutdown() to run onShutdown against never-applied state, it would + // throw (the manager's AddressSpace fragment was never started here) and turn routine + // disposal into a second failure. + @Test + void failedStartupLeavesManagerStoppedAndSubsequentShutdownIsANoOp() { + AliasManager manager = + new AliasManager( + server, AliasManagerConfig.builder().versionStore(failingVersionStore()).build()); + + assertThrows(IllegalStateException.class, manager::startup); + + assertTrue(manager.isNotRunning()); + + // The failed startup already rolled back, so there is nothing to undo: this must return + // silently without touching the AddressSpace. + manager.shutdown(); + + assertTrue(manager.isNotRunning()); + assertNoManagerBaseline(); + } + + // WHY: the no-trace guarantee covers loaded versions too — loadPersisted must not publish to + // the LastChange Properties before every fallible startup step has succeeded, or a failed + // startup would leave persisted values published into the AddressSpace. + @Test + void failedStartupDoesNotPublishLoadedVersionsToLastChangeProperties() throws UaException { + var nodeManagerRef = new AtomicReference<@Nullable UaNodeManager>(); + testNamespace.configure((context, nodeManager) -> nodeManagerRef.set(nodeManager)); + UaNodeManager nodeManager = requireNonNull(nodeManagerRef.get()); + + NodeId categoryId = newNodeId("DeferralCategory"); + + // Setup: a first manager creates a category whose LastChange Property gets a published + // value, then shuts down; the category survives in the application namespace. + AliasManager setupManager = new AliasManager(server, AliasManagerConfig.builder().build()); + setupManager.startup(); + try { + setupManager.addCategory( + new AliasCategoryConfig( + categoryId, + NodeIds.Aliases, + newQualifiedName("DeferralCategory"), + nodeManager, + name -> newNodeId("DeferralCat/" + name), + true, + false, + false)); + } finally { + setupManager.shutdown(); + } + + try { + UInteger publishedBefore = requireLastChange(server, categoryId); + UInteger farFuture = uint(4_000_000_000L); + + // A store that claims a far-future persisted version for the category, has no entry for + // the root, and cannot save: startup loads the value and then fails at the root + // LastChange initialization — i.e. AFTER the load. + var store = + new AliasVersionStore() { + @Override + public Map load() { + return Map.of(categoryId.expanded(server.getNamespaceTable()), farFuture); + } + + @Override + public void save(ExpandedNodeId id, UInteger value) throws UaException { + throw new UaException(StatusCodes.Bad_ResourceUnavailable, "save failed"); + } + }; + + AliasManager manager = + new AliasManager(server, AliasManagerConfig.builder().versionStore(store).build()); + + assertThrows(IllegalStateException.class, manager::startup); + + // The loaded far-future value was never published. + assertEquals(publishedBefore, requireLastChange(server, categoryId)); + assertNoManagerBaseline(); + } finally { + server.getAddressSpaceManager().getManagedNode(categoryId).ifPresent(UaNode::delete); + } + } + + /** An {@link AliasVersionStore} whose load fails, simulating unreadable persisted state. */ + private AliasVersionStore failingVersionStore() { + return new AliasVersionStore() { + @Override + public Map load() throws UaException { + throw new UaException(StatusCodes.Bad_InternalError, "persisted state unreadable"); + } + + @Override + public void save(ExpandedNodeId categoryId, UInteger value) {} + }; + } + } + + /** Resolve a standard Method Node from the running server, failing the test if it is absent. */ + private UaMethodNode serverMethodNode(NodeId nodeId) { + UaNode node = server.getAddressSpaceManager().getManagedNode(nodeId).orElseThrow(); + + return assertInstanceOf(UaMethodNode.class, node); + } + + /** Assert every standard {@code FindAlias} Node is in the unbound, non-executable baseline. */ + private void assertNoManagerBaseline() { + for (NodeId nodeId : STANDARD_FIND_ALIAS_NODE_IDS) { + UaMethodNode methodNode = serverMethodNode(nodeId); + + assertInstanceOf( + MethodInvocationHandler.NotImplementedHandler.class, methodNode.getInvocationHandler()); + assertFalse(methodNode.isExecutable()); + assertFalse(methodNode.isUserExecutable()); + } + } + + /** + * Call {@code FindAlias}/{@code FindAliasVerbose} on {@code objectId} through the client with + * pattern {@code "%"} and no ReferenceType filter. + */ + private CallMethodResult callFindAlias(NodeId objectId, NodeId methodId) throws UaException { + CallResponse response = + client.call( + List.of( + new CallMethodRequest( + objectId, + methodId, + new Variant[] {new Variant("%"), new Variant(NodeId.NULL_VALUE)}))); + + return requireNonNull(response.getResults())[0]; + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasMutationTest.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasMutationTest.java new file mode 100644 index 0000000000..0637814e5d --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasMutationTest.java @@ -0,0 +1,727 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.test.aliases; + +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.assertStrictlyGreater; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readAttribute; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readLastChange; +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.requireLastChange; +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.eclipse.milo.opcua.sdk.core.Reference; +import org.eclipse.milo.opcua.sdk.server.UaNodeManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategory; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManager; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasTarget; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest; +import org.eclipse.milo.opcua.stack.core.AttributeId; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseDirection; +import org.eclipse.milo.opcua.stack.core.types.enumerated.BrowseResultMask; +import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowseDescription; +import org.eclipse.milo.opcua.stack.core.types.structured.BrowseResult; +import org.eclipse.milo.opcua.stack.core.types.structured.ReadResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId; +import org.eclipse.milo.opcua.stack.core.types.structured.ReferenceDescription; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Integration tests for {@link AliasManager}'s programmatic mutation API ({@code addAlias}, {@code + * deleteAlias}, {@code addCategory}, {@code removeCategory}, {@code touch}) and the {@code + * LastChange} version invariant, exercised against a running client/server pair. + * + *

The manager is started once for the class, after the server, and shut down before it; tests + * use per-test alias and category names, and assert version changes relative to values captured + * within the same test, so they are order-independent. + */ +// Fields below are assigned in @BeforeAll, which the nullability inspection does not model. +@SuppressWarnings("NotNullFieldNotInitialized") +class AliasMutationTest extends AbstractClientServerTest { + + private RecordingVersionStore versionStore; + private AliasManager aliasManager; + private UaNodeManager testNodeManager; + + @BeforeAll + void startAliasManager() { + testNamespace.configure((context, nodeManager) -> testNodeManager = nodeManager); + + versionStore = new RecordingVersionStore(server); + + AliasManagerConfig config = + AliasManagerConfig.builder() + .versionStore(versionStore) + .nodeNamespaceIndex(testNamespace.getNamespaceIndex()) + .build(); + + aliasManager = new AliasManager(server, config); + aliasManager.startup(); + } + + @AfterAll + void shutdownAliasManager() { + aliasManager.shutdown(); + } + + @Nested + class AddAlias { + + // WHY: pins the AddressSpace-fragment hosting rule: alias Nodes created in a standard (ns=0) + // category live in the manager's own fragment, and Read and Browse service calls must still + // route to them (Part 17 §6.3.1 alias model; fragment registration design invariant). + @Test + void addAliasToStandardCategoryCreatesClientBrowseableAliasNode() throws Exception { + NodeId target = newNodeId("TestInt32"); + + NodeId aliasNodeId = + aliasManager.addAlias(NodeIds.Aliases, "BrowseableAlias", List.of(aliasFor(target))); + + // The client can Read the alias Node's BrowseName attribute. + ReadResponse response = + client.read( + 0.0, + TimestampsToReturn.Neither, + List.of( + new ReadValueId( + aliasNodeId, AttributeId.BrowseName.uid(), null, QualifiedName.NULL_VALUE))); + + DataValue[] results = response.getResults(); + assertNotNull(results); + DataValue browseNameValue = results[0]; + assertTrue( + browseNameValue.statusCode().isGood(), + () -> "BrowseName read failed: " + browseNameValue.statusCode()); + assertEquals( + new QualifiedName(aliasNodeId.getNamespaceIndex(), "BrowseableAlias"), + browseNameValue.value().value()); + + // The client can Browse the alias Node's forward AliasFor Reference to the target. + BrowseResult aliasBrowse = + client.browse( + new BrowseDescription( + aliasNodeId, + BrowseDirection.Forward, + NodeIds.AliasFor, + true, + uint(0), + uint(BrowseResultMask.All.getValue()))); + + assertTrue( + aliasBrowse.getStatusCode().isGood(), + () -> "Browse failed: " + aliasBrowse.getStatusCode()); + ReferenceDescription[] aliasReferences = aliasBrowse.getReferences(); + assertNotNull(aliasReferences); + assertEquals(1, aliasReferences.length); + assertEquals( + target, + aliasReferences[0].getNodeId().toNodeId(server.getNamespaceTable()).orElseThrow()); + + // Browsing the standard category shows the alias, even though the category Node and the + // alias Node live in different NodeManagers (composite Reference gathering). + BrowseResult categoryBrowse = + client.browse( + new BrowseDescription( + NodeIds.Aliases, + BrowseDirection.Forward, + NodeIds.Organizes, + true, + uint(0), + uint(BrowseResultMask.All.getValue()))); + + assertTrue( + categoryBrowse.getStatusCode().isGood(), + () -> "Browse failed: " + categoryBrowse.getStatusCode()); + ReferenceDescription[] categoryReferences = categoryBrowse.getReferences(); + assertNotNull(categoryReferences); + boolean organized = + Stream.of(categoryReferences) + .anyMatch( + reference -> + aliasNodeId.equals( + reference.getNodeId().toNodeId(server.getNamespaceTable()).orElse(null))); + assertTrue(organized, "Aliases category does not organize the created alias Node"); + } + + // WHY: Part 17 §6.2 — "The string part of the BrowseName shall be the DisplayName with an + // empty locale id and no other locale shall be provided." A locale like "en" violates the + // spec and breaks Clients that compare the DisplayName as a whole LocalizedText. + @Test + void aliasDisplayNameIsBrowseNameTextWithEmptyLocale() throws Exception { + NodeId aliasNodeId = + aliasManager.addAlias( + NodeIds.Aliases, "EmptyLocaleAlias", List.of(aliasFor(newNodeId("TestInt32")))); + + DataValue value = readAttribute(client, aliasNodeId, AttributeId.DisplayName); + Object rawDisplayName = value.value().value(); + assertNotNull(rawDisplayName); + LocalizedText displayName = (LocalizedText) rawDisplayName; + + assertEquals("EmptyLocaleAlias", displayName.text()); + assertNull(displayName.locale(), "Part 17 §6.2 requires an empty locale id"); + } + + // WHY: Part 17 §6.3.4 — adding an alias that already exists with the same target is not a + // change; the same Node must be reused and LastChange must not advance (no store write, no + // Property change). + @Test + void identicalReAddReturnsSameNodeIdWithoutBumpingLastChange() throws Exception { + NodeId target = newNodeId("TestInt32"); + + NodeId first = + aliasManager.addAlias(NodeIds.Aliases, "IdempotentAlias", List.of(aliasFor(target))); + + UInteger before = requireLastChange(server, NodeIds.Aliases); + int savesBefore = versionStore.saves().size(); + + NodeId second = + aliasManager.addAlias(NodeIds.Aliases, "IdempotentAlias", List.of(aliasFor(target))); + + assertEquals(first, second); + assertEquals(before, readLastChange(server, NodeIds.Aliases)); + assertEquals(savesBefore, versionStore.saves().size()); + } + + // WHY: Part 17 §6.3.4 — adding an existing alias name with a new target extends the alias + // with another AliasFor Reference, which is an AddressSpace change and must bump LastChange + // (§6.3.1: LastChange covers changes to alias References). + @Test + void addingNewTargetToExistingAliasAddsReferenceAndBumpsLastChange() throws Exception { + NodeId firstTarget = newNodeId("TestInt32"); + NodeId secondTarget = newNodeId("TestAnalogValue"); + + NodeId aliasNodeId = + aliasManager.addAlias(NodeIds.Aliases, "ExtendedAlias", List.of(aliasFor(firstTarget))); + + UInteger before = requireLastChange(server, NodeIds.Aliases); + + NodeId again = + aliasManager.addAlias(NodeIds.Aliases, "ExtendedAlias", List.of(aliasFor(secondTarget))); + + assertEquals(aliasNodeId, again); + assertEquals(2, aliasForReferences(aliasNodeId).size()); + assertStrictlyGreater(before, readLastChange(server, NodeIds.Aliases)); + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class AddAliasValidation { + + // WHY: Part 17 §6.3.4 defines the per-alias failure statuses for invalid add requests, and + // Part 17 §9.3 restricts TagVariables to aliases whose AliasFor References point to + // Variables; the programmatic path must reject each case with the corresponding status. + @ParameterizedTest(name = "{0}") + @MethodSource("invalidTargets") + void addAliasRejectsInvalidTarget( + String description, + NodeId categoryId, + String aliasName, + AliasTarget target, + long expectedStatus) { + + UaException e = + assertThrows( + UaException.class, + () -> aliasManager.addAlias(categoryId, aliasName, List.of(target))); + + assertEquals(expectedStatus, e.getStatusCode().getValue(), description); + } + + Stream invalidTargets() { + return Stream.of( + Arguments.of( + "non-AliasFor ReferenceType is rejected", + NodeIds.Aliases, + "InvalidRefTypeAlias", + new AliasTarget(newNodeId("TestInt32").expanded(), null, NodeIds.Organizes), + StatusCodes.Bad_InvalidArgument), + Arguments.of( + "unknown local target is rejected", + NodeIds.Aliases, + "UnknownTargetAlias", + aliasFor(newNodeId("NoSuchTargetNode")), + StatusCodes.Bad_NodeIdUnknown), + Arguments.of( + "remote target is rejected", + NodeIds.Aliases, + "RemoteTargetAlias", + new AliasTarget( + newNodeId("TestInt32").expanded(), "urn:remote:server", NodeIds.AliasFor), + StatusCodes.Bad_NotSupported), + Arguments.of( + "Method target under TagVariables violates the NodeClass constraint", + NodeIds.TagVariables, + "MethodTagAlias", + aliasFor(newNodeId("sqrt(x)")), + StatusCodes.Bad_InvalidArgument)); + } + + // WHY: Part 17 §9.3 — TagVariables restricts alias targets to Variables; a Variable target + // is the allowed case and must succeed, proving the constraint rejects NodeClass, not the + // category itself. + @Test + void variableTargetUnderTagVariablesIsAccepted() throws Exception { + NodeId aliasNodeId = + aliasManager.addAlias( + NodeIds.TagVariables, "VariableTagAlias", List.of(aliasFor(newNodeId("TestInt32")))); + + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()); + } + } + + @Nested + class DeleteAlias { + + // WHY: Part 17 §6.3.5 delete semantics — removing one explicit target only removes that + // AliasFor Reference; the alias Object survives while other targets remain, and the change + // bumps the category's LastChange (§6.3.1). + @Test + void removingOneExplicitTargetKeepsAliasNodeWhileAnotherTargetRemains() throws Exception { + NodeId keptTarget = newNodeId("TestInt32"); + NodeId removedTarget = newNodeId("TestAnalogValue"); + + NodeId aliasNodeId = + aliasManager.addAlias( + NodeIds.Aliases, + "PartialDeleteAlias", + List.of(aliasFor(keptTarget), aliasFor(removedTarget))); + + UInteger before = requireLastChange(server, NodeIds.Aliases); + + aliasManager.deleteAlias( + NodeIds.Aliases, "PartialDeleteAlias", List.of(aliasFor(removedTarget))); + + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()); + + List remaining = aliasForReferences(aliasNodeId); + assertEquals(1, remaining.size()); + assertEquals(keptTarget.expanded(), remaining.get(0).getTargetNodeId()); + + assertStrictlyGreater(before, readLastChange(server, NodeIds.Aliases)); + } + + // WHY: Part 17 §6.3.1 — an alias without at least one AliasFor Reference violates the model, + // so removing the last target deletes the alias Object from EVERY organizing category, and + // every one of those categories (plus their ancestors) gets a LastChange bump. + @Test + void removingLastTargetDeletesAliasFromEveryOrganizingCategoryAndBumpsEach() throws Exception { + AliasCategory catA = aliasManager.addCategory(categoryConfig("DeleteCatA", NodeIds.Aliases)); + AliasCategory catB = aliasManager.addCategory(categoryConfig("DeleteCatB", NodeIds.Aliases)); + + NodeId target = newNodeId("TestInt32"); + NodeId aliasNodeId = + aliasManager.addAlias(catA.nodeId(), "SharedAlias", List.of(aliasFor(target))); + + // Organize the alias by a second category, the multi-parent arrangement §6.3.1 allows. + UaNode aliasNode = server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElseThrow(); + aliasNode.addReference( + new Reference( + aliasNodeId, + NodeIds.Organizes, + catB.nodeId().expanded(), + Reference.Direction.INVERSE)); + + UInteger rootBefore = requireLastChange(server, NodeIds.Aliases); + UInteger catABefore = requireLastChange(server, catA.nodeId()); + UInteger catBBefore = requireLastChange(server, catB.nodeId()); + + aliasManager.deleteAlias(catA.nodeId(), "SharedAlias", List.of(aliasFor(target))); + + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isEmpty()); + assertTrue(aliasManager.findAlias(catB.nodeId(), "SharedAlias", null).isEmpty()); + + assertStrictlyGreater(catABefore, readLastChange(server, catA.nodeId())); + assertStrictlyGreater(catBBefore, readLastChange(server, catB.nodeId())); + assertStrictlyGreater(rootBefore, readLastChange(server, NodeIds.Aliases)); + } + + // WHY: only alias linkage (AliasFor or a subtype) may be removed through deleteAlias; a + // caller-supplied structural ReferenceType like HasTypeDefinition must be rejected with + // Bad_InvalidArgument BEFORE anything is removed, or the alias Node would be corrupted and + // become invisible to lookup (Part 17 §6.3.1 model integrity). + @Test + void nonAliasForReferenceTypeIsRejectedAndAliasNodeIsLeftUndamaged() throws Exception { + NodeId target = newNodeId("TestInt32"); + + NodeId aliasNodeId = + aliasManager.addAlias(NodeIds.Aliases, "GuardedAlias", List.of(aliasFor(target))); + + UInteger before = requireLastChange(server, NodeIds.Aliases); + + // The malicious shape: the alias's own HasTypeDefinition Reference expressed as a target. + UaException e = + assertThrows( + UaException.class, + () -> + aliasManager.deleteAlias( + NodeIds.Aliases, + "GuardedAlias", + List.of( + new AliasTarget( + NodeIds.AliasNameType.expanded(), null, NodeIds.HasTypeDefinition)))); + + assertEquals(StatusCodes.Bad_InvalidArgument, e.getStatusCode().getValue()); + + // The HasTypeDefinition Reference is intact. + List typeDefinitions = + server + .getAddressSpaceManager() + .getManagedReferences(aliasNodeId, Reference.HAS_TYPE_DEFINITION_PREDICATE); + assertEquals(1, typeDefinitions.size()); + assertEquals(NodeIds.AliasNameType.expanded(), typeDefinitions.get(0).getTargetNodeId()); + + // The alias is still found by lookup (which depends on the type definition)... + List found = aliasManager.findAlias(NodeIds.Aliases, "GuardedAlias", null); + assertEquals(1, found.size()); + assertEquals("GuardedAlias", found.get(0).getAliasName().name()); + + // ...and by the idempotent add path, which returns the same undamaged Node. + NodeId reFound = + aliasManager.addAlias(NodeIds.Aliases, "GuardedAlias", List.of(aliasFor(target))); + assertEquals(aliasNodeId, reFound); + + // Nothing changed, so nothing bumped. + assertEquals(before, readLastChange(server, NodeIds.Aliases)); + } + } + + @Nested + class AddCategory { + + // WHY: AliasCategoryConfig's aliasNodeIdFactory is reserved for alias Nodes — the category + // Node's NodeId comes from categoryNodeId — so category creation must not consume a factory + // invocation. A stateful (e.g. sequence-allocating) factory stays aligned with the aliases + // actually created, and an alias may share its name with its category without colliding on + // the category's NodeId. + @Test + void addCategoryDoesNotInvokeAliasNodeIdFactoryAndAliasMayShareCategoryName() throws Exception { + var factoryInvocations = new AtomicInteger(); + NodeId categoryId = newNodeId("FactoryReservedCat"); + + AliasCategory category = + aliasManager.addCategory( + new AliasCategoryConfig( + categoryId, + NodeIds.Aliases, + newQualifiedName("FactoryReservedCat"), + testNodeManager, + aliasName -> { + factoryInvocations.incrementAndGet(); + return newNodeId("FactoryReservedCat/alias/" + aliasName); + }, + false, + false, + false)); + try { + assertEquals(categoryId, category.nodeId()); + assertEquals( + 0, + factoryInvocations.get(), + "addCategory must not invoke the aliasNodeIdFactory for the category Node"); + + // An alias whose name is identical to the category's name text. + NodeId aliasNodeId = + aliasManager.addAlias( + categoryId, "FactoryReservedCat", List.of(aliasFor(newNodeId("TestInt32")))); + + assertEquals(1, factoryInvocations.get()); + assertNotEquals(categoryId, aliasNodeId); + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()); + } finally { + server + .getAddressSpaceManager() + .getManagedNode(newNodeId("FactoryReservedCat/alias/FactoryReservedCat")) + .ifPresent(UaNode::delete); + aliasManager.removeCategory(categoryId); + } + } + } + + @Nested + class RemoveCategory { + + // WHY: removeCategory's contract keeps after-added alias Nodes alive while deleting the + // created category Node; an Organizes linkage left behind would be a Reference targeting a + // deleted NodeId — dangling for Browse, and silently reattaching the alias if a category + // with the same NodeId were created later — so the manager must detach every directly + // organized alias from both sides before deleting the category. + @Test + void removeCategoryDetachesSurvivingAliasesFromTheDeletedCategory() throws Exception { + AliasCategory category = + aliasManager.addCategory(categoryConfig("DetachCat", NodeIds.Aliases)); + NodeId categoryId = category.nodeId(); + + NodeId aliasNodeId = + aliasManager.addAlias( + categoryId, "DetachedAlias", List.of(aliasFor(newNodeId("TestInt32")))); + try { + aliasManager.removeCategory(categoryId); + + // The category Node is gone, but the alias Node survives, per the contract. + assertTrue(server.getAddressSpaceManager().getManagedNode(categoryId).isEmpty()); + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()); + + // No Reference on the alias Node targets the deleted category NodeId anymore. + List danglingReferences = + server + .getAddressSpaceManager() + .getManagedReferences( + aliasNodeId, + reference -> + categoryId.equals( + reference + .getTargetNodeId() + .toNodeId(server.getNamespaceTable()) + .orElse(null))); + assertEquals(List.of(), danglingReferences); + } finally { + server.getAddressSpaceManager().getManagedNode(aliasNodeId).ifPresent(UaNode::delete); + if (server.getAddressSpaceManager().getManagedNode(categoryId).isPresent()) { + aliasManager.removeCategory(categoryId); + } + } + } + } + + @Nested + class LastChange { + + // WHY: Part 17 §9.2 — the LastChange Property is mandatory on the root Aliases instance; the + // manager must initialize (or restore) it at startup and seed the persistent store. + @Test + void rootAliasesLastChangeIsNonNullAfterStartup() { + assertNotNull(readLastChange(server, NodeIds.Aliases)); + assertTrue( + versionStore.load().containsKey(NodeIds.Aliases.expanded(server.getNamespaceTable()))); + } + + // WHY: Part 4 §7.43 — VersionTime values must let a Client detect every change, so each + // mutation must produce a strictly greater value even within the same wall-clock second. + @Test + void everyMutationBumpsRootLastChangeStrictlyMonotonically() throws Exception { + NodeId firstTarget = newNodeId("TestInt32"); + NodeId secondTarget = newNodeId("TestAnalogValue"); + + var observed = new ArrayList(); + observed.add(requireLastChange(server, NodeIds.Aliases)); + + aliasManager.addAlias(NodeIds.Aliases, "MonotonicAlias", List.of(aliasFor(firstTarget))); + observed.add(requireLastChange(server, NodeIds.Aliases)); + + aliasManager.addAlias(NodeIds.Aliases, "MonotonicAlias", List.of(aliasFor(secondTarget))); + observed.add(requireLastChange(server, NodeIds.Aliases)); + + aliasManager.deleteAlias(NodeIds.Aliases, "MonotonicAlias", List.of(aliasFor(secondTarget))); + observed.add(requireLastChange(server, NodeIds.Aliases)); + + aliasManager.deleteAlias(NodeIds.Aliases, "MonotonicAlias", null); + observed.add(requireLastChange(server, NodeIds.Aliases)); + + for (int i = 0; i < observed.size() - 1; i++) { + UInteger earlier = observed.get(i); + UInteger later = observed.get(i + 1); + assertTrue( + later.longValue() > earlier.longValue(), + () -> "expected strictly increasing LastChange, got " + observed); + } + } + + // WHY: Part 17 §6.3.1 — LastChange reflects changes to the category "or any AliasNames below + // it", so a mutation in a nested category must bump every category on the Organizes chain up + // to the root Aliases Object. + @Test + void addAliasInNestedCategoryBumpsParentChainUpToRoot() throws Exception { + AliasCategory child = aliasManager.addCategory(categoryConfig("PropChild", NodeIds.Aliases)); + AliasCategory grandchild = + aliasManager.addCategory(categoryConfig("PropGrandchild", child.nodeId())); + + UInteger rootBefore = requireLastChange(server, NodeIds.Aliases); + UInteger childBefore = requireLastChange(server, child.nodeId()); + UInteger grandchildBefore = requireLastChange(server, grandchild.nodeId()); + + aliasManager.addAlias( + grandchild.nodeId(), "PropagationAlias", List.of(aliasFor(newNodeId("TestInt32")))); + + assertStrictlyGreater(grandchildBefore, readLastChange(server, grandchild.nodeId())); + assertStrictlyGreater(childBefore, readLastChange(server, child.nodeId())); + assertStrictlyGreater(rootBefore, readLastChange(server, NodeIds.Aliases)); + } + + // WHY: Part 17 §9.2 requires the root LastChange value to survive restarts; persisting AFTER + // publishing would open a window where a Client caches a version that is lost by a crash and + // silently regresses. The store must therefore see each value before the Property does. + @Test + void lastChangeIsPersistedToStoreBeforeThePropertyPublishesIt() throws Exception { + int savesBefore = versionStore.saves().size(); + + aliasManager.touch(NodeIds.Aliases); + + List newSaves = + versionStore.saves().subList(savesBefore, versionStore.saves().size()).stream() + .filter(record -> NodeIds.Aliases.equals(record.categoryId())) + .toList(); + + assertEquals(1, newSaves.size()); + RecordingVersionStore.SaveRecord record = newSaves.get(0); + + // At save time the Property still showed the previous value... + assertNotEquals(record.value(), record.lastChangeAtSave()); + // ...and afterwards the Property shows exactly the persisted value. + assertEquals(record.value(), readLastChange(server, NodeIds.Aliases)); + assertEquals(record.value(), versionStore.load().get(record.storeKey())); + + // The store key is namespace-URI-qualified, so the persisted entry stays valid across + // restarts even when the namespace table assigns the namespace a different index. + assertTrue( + record.storeKey().isAbsolute(), + () -> "expected a URI-qualified store key, got " + record.storeKey().toParseableString()); + } + + // WHY: Part 17 §6.3.1 — removing a category is a change below its ancestors, so the + // surviving ancestor chain must be bumped even though the removed category itself is gone. + @Test + void removeCategoryBumpsSurvivingAncestorCategories() throws Exception { + AliasCategory category = + aliasManager.addCategory(categoryConfig("RemovableCategory", NodeIds.Aliases)); + + UInteger rootBefore = requireLastChange(server, NodeIds.Aliases); + + aliasManager.removeCategory(category.nodeId()); + + assertTrue(server.getAddressSpaceManager().getManagedNode(category.nodeId()).isEmpty()); + assertStrictlyGreater(rootBefore, readLastChange(server, NodeIds.Aliases)); + + // Removal also deletes the category's persisted entry, so durable stores do not + // accumulate entries for categories that no longer exist. + assertFalse( + versionStore.load().containsKey(category.nodeId().expanded(server.getNamespaceTable()))); + } + + // WHY: a target change is observable from EVERY category that organizes the alias, not just + // the one the mutation was addressed to (design ambiguity-resolution principle; Part 17 + // §6.3.1's LastChange contract covers changes to the aliases below a category), so adding or + // removing a target through one organizing category must bump the other organizing + // category's LastChange too. + @Test + void targetChangeThroughOneCategoryBumpsLastChangeOfEveryOrganizingCategory() throws Exception { + AliasCategory catA = + aliasManager.addCategory(categoryConfig("SharedTargetCatA", NodeIds.Aliases)); + AliasCategory catB = + aliasManager.addCategory(categoryConfig("SharedTargetCatB", NodeIds.Aliases)); + + NodeId keptTarget = newNodeId("TestInt32"); + NodeId extraTarget = newNodeId("TestAnalogValue"); + + NodeId aliasNodeId = + aliasManager.addAlias(catA.nodeId(), "SharedTargetAlias", List.of(aliasFor(keptTarget))); + try { + // Organize the alias by a second category, the multi-parent arrangement §6.3.1 allows. + UaNode aliasNode = + server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElseThrow(); + aliasNode.addReference( + new Reference( + aliasNodeId, + NodeIds.Organizes, + catB.nodeId().expanded(), + Reference.Direction.INVERSE)); + + UInteger catBBeforeAdd = requireLastChange(server, catB.nodeId()); + + aliasManager.addAlias(catA.nodeId(), "SharedTargetAlias", List.of(aliasFor(extraTarget))); + + assertStrictlyGreater(catBBeforeAdd, readLastChange(server, catB.nodeId())); + + UInteger catBBeforeDelete = requireLastChange(server, catB.nodeId()); + + aliasManager.deleteAlias( + catA.nodeId(), "SharedTargetAlias", List.of(aliasFor(extraTarget))); + + // The alias survives with its remaining target — this was a target change, not a + // deletion — and the un-addressed organizing category is still bumped. + assertTrue(server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()); + assertEquals(1, aliasForReferences(aliasNodeId).size()); + assertStrictlyGreater(catBBeforeDelete, readLastChange(server, catB.nodeId())); + } finally { + server.getAddressSpaceManager().getManagedNode(aliasNodeId).ifPresent(UaNode::delete); + aliasManager.removeCategory(catA.nodeId()); + aliasManager.removeCategory(catB.nodeId()); + } + } + + // WHY: touch is the documented escape hatch for out-of-band AddressSpace edits (Part 17 + // §6.3.1 requires LastChange to cover them); it must bump even though the manager itself + // observed no mutation. + @Test + void touchBumpsCategoryLastChange() throws Exception { + UInteger before = requireLastChange(server, NodeIds.Aliases); + + aliasManager.touch(NodeIds.Aliases); + + assertStrictlyGreater(before, readLastChange(server, NodeIds.Aliases)); + } + } + + private AliasTarget aliasFor(NodeId targetNodeId) { + return new AliasTarget(targetNodeId.expanded(), null, NodeIds.AliasFor); + } + + private AliasCategoryConfig categoryConfig(String name, NodeId parentCategoryId) { + return new AliasCategoryConfig( + newNodeId(name), + parentCategoryId, + newQualifiedName(name), + testNodeManager, + aliasName -> newNodeId(name + "/" + aliasName), + true, + false, + false); + } + + /** The forward {@code AliasFor} References of the alias Node, across all NodeManagers. */ + private List aliasForReferences(NodeId aliasNodeId) { + return server + .getAddressSpaceManager() + .getManagedReferences( + aliasNodeId, + reference -> + reference.isForward() && NodeIds.AliasFor.equals(reference.getReferenceTypeId())); + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasTestSupport.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasTestSupport.java new file mode 100644 index 0000000000..b6ed67105c --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/AliasTestSupport.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.test.aliases; + +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.eclipse.milo.opcua.sdk.client.OpcUaClient; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType; +import org.eclipse.milo.opcua.stack.core.AttributeId; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn; +import org.eclipse.milo.opcua.stack.core.types.structured.ReadResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId; +import org.jspecify.annotations.Nullable; + +/** + * Static helpers shared by the alias integration tests: client-side attribute reads and server-side + * {@code LastChange} Property access and assertions. + */ +final class AliasTestSupport { + + private AliasTestSupport() {} + + /** Read a single attribute of a Node through the client. */ + static DataValue readAttribute(OpcUaClient client, NodeId nodeId, AttributeId attributeId) + throws UaException { + ReadResponse response = + client.read( + 0.0, + TimestampsToReturn.Neither, + List.of(new ReadValueId(nodeId, attributeId.uid(), null, QualifiedName.NULL_VALUE))); + + return requireNonNull(response.getResults())[0]; + } + + /** Read the category's LastChange Property, asserting that it has one with a non-null value. */ + static UInteger requireLastChange(OpcUaServer server, NodeId categoryId) { + UInteger value = readLastChange(server, categoryId); + assertNotNull(value, () -> "no LastChange value on " + categoryId.toParseableString()); + return value; + } + + static void assertStrictlyGreater(UInteger before, @Nullable UInteger after) { + assertNotNull(after); + assertTrue( + after.longValue() > before.longValue(), + () -> "expected LastChange > " + before + " but was " + after); + } + + /** + * The current value of the category's {@code LastChange} Property Node, or null if the category + * has no such Property or it has no value yet. + */ + static @Nullable UInteger readLastChange(OpcUaServer server, NodeId categoryId) { + return server + .getAddressSpaceManager() + .getManagedNode(categoryId) + .flatMap(categoryNode -> categoryNode.getPropertyNode(AliasNameCategoryType.LAST_CHANGE)) + .map(propertyNode -> (UInteger) propertyNode.getValue().value().value()) + .orElse(null); + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/RecordingVersionStore.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/RecordingVersionStore.java new file mode 100644 index 0000000000..ba842e0aa1 --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/RecordingVersionStore.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.test.aliases; + +import static org.eclipse.milo.opcua.sdk.test.aliases.AliasTestSupport.readLastChange; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.sdk.server.aliases.AliasVersionStore; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.jspecify.annotations.Nullable; + +/** + * An in-memory {@link AliasVersionStore} that records every save together with the {@code + * LastChange} Property value visible at save time, letting tests assert the persist-then-publish + * ordering, inspect exactly which categories were persisted (and that their store keys are + * namespace-URI-qualified), and count how many times a mutation call persisted a category's {@code + * LastChange}. + */ +final class RecordingVersionStore implements AliasVersionStore { + + /** + * One recorded {@link #save}: the URI-qualified store key and its runtime NodeId resolution, the + * persisted value, and the {@code LastChange} Property value that was observable at the moment + * the store was called. + */ + record SaveRecord( + ExpandedNodeId storeKey, + NodeId categoryId, + UInteger value, + @Nullable UInteger lastChangeAtSave) {} + + private final Map entries = new ConcurrentHashMap<>(); + private final List saves = new CopyOnWriteArrayList<>(); + + private final OpcUaServer server; + + RecordingVersionStore(OpcUaServer server) { + this.server = server; + } + + @Override + public Map load() { + return Map.copyOf(entries); + } + + @Override + public void save(ExpandedNodeId categoryId, UInteger value) { + NodeId localCategoryId = categoryId.toNodeId(server.getNamespaceTable()).orElseThrow(); + + // Capture the Property value BEFORE recording, so tests can verify the manager persists + // each version before publishing it via the Property. + saves.add( + new SaveRecord( + categoryId, localCategoryId, value, readLastChange(server, localCategoryId))); + entries.put(categoryId, value); + } + + @Override + public void delete(ExpandedNodeId categoryId) { + entries.remove(categoryId); + } + + List saves() { + return saves; + } + + /** The number of {@link #save} calls recorded for the category with {@code categoryId}. */ + long savesFor(NodeId categoryId) { + return saves.stream().filter(record -> categoryId.equals(record.categoryId())).count(); + } +} diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/package-info.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/package-info.java new file mode 100644 index 0000000000..577d97a89d --- /dev/null +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/aliases/package-info.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/** + * Client/server integration tests for OPC UA Part 17 Alias Names support ({@link + * org.eclipse.milo.opcua.sdk.server.aliases.AliasManager} and collaborators). + * + *

Test model

+ * + *

Tests run against the shared per-class client and server owned by {@link + * org.eclipse.milo.opcua.sdk.test.AbstractClientServerTest}. An {@code AliasManager} has a one-shot + * lifecycle (no restart after shutdown), so tests never share a manager: each test constructs a + * fresh instance against the running server, starts it if the scenario calls for it, and shuts it + * down (or discards it, for failed-startup scenarios) before returning. + * + *

Because a manager shutdown restores exactly the no-manager baseline that the standard + * namespace establishes at server startup — {@code FindAlias} Methods unbound and non-executable — + * tests that assert the "no manager installed" state and tests that install a manager can coexist + * in one server without ordering constraints, provided every test cleans up the Nodes it created. + */ +@NullMarked +package org.eclipse.milo.opcua.sdk.test.aliases; + +import org.jspecify.annotations.NullMarked; diff --git a/opc-ua-sdk/sdk-core/src/main/java/org/eclipse/milo/opcua/sdk/core/Reference.java b/opc-ua-sdk/sdk-core/src/main/java/org/eclipse/milo/opcua/sdk/core/Reference.java index 191395fbcc..7d4c41c3c2 100644 --- a/opc-ua-sdk/sdk-core/src/main/java/org/eclipse/milo/opcua/sdk/core/Reference.java +++ b/opc-ua-sdk/sdk-core/src/main/java/org/eclipse/milo/opcua/sdk/core/Reference.java @@ -193,6 +193,10 @@ public String toString() { (reference) -> reference.isForward() && NodeIds.Organizes.equals(reference.getReferenceTypeId()); + public static final Predicate ORGANIZED_BY_PREDICATE = + (reference) -> + reference.isInverse() && NodeIds.Organizes.equals(reference.getReferenceTypeId()); + public static final Predicate HAS_ENCODING_PREDICATE = (reference) -> reference.isForward() && NodeIds.HasEncoding.equals(reference.getReferenceTypeId()); diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AddAliasesToCategoryMethodImpl.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AddAliasesToCategoryMethodImpl.java new file mode 100644 index 0000000000..28fa174a4c --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AddAliasesToCategoryMethodImpl.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.eclipse.milo.opcua.sdk.server.Session; +import org.eclipse.milo.opcua.sdk.server.methods.Out; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType; +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; + +/** + * Network-facing {@code AddAliasesToCategory} implementation: authorizes the calling session + * through the {@link AliasAuthorizationPolicy}, then delegates to the {@link AliasManager}'s + * per-entry mutation path, targeting the category Object the Method was called on. + * + *

The category is re-resolved from the call's Object NodeId on every invocation, so a call + * racing a category removal fails with {@code Bad_NodeIdUnknown} instead of observing stale state. + * + *

Call-level failures (invalid array shapes, an invalid {@code TargetReferenceType}, operation + * count over the configured limit, denied authorization) fail the whole call; everything else is + * reported per entry through the {@code ErrorCodes} output, with one StatusCode per input entry. + */ +class AddAliasesToCategoryMethodImpl extends AliasNameCategoryType.AddAliasesToCategoryMethod { + + private final AliasManager aliasManager; + private final AliasAuthorizationPolicy policy; + + AddAliasesToCategoryMethodImpl( + UaMethodNode node, AliasManager aliasManager, AliasAuthorizationPolicy policy) { + + super(node); + + this.aliasManager = aliasManager; + this.policy = policy; + } + + @Override + protected void invoke( + InvocationContext context, + String[] aliasNames, + ExpandedNodeId[] targetNodes, + String[] targetServers, + NodeId targetReferenceType, + Out errorCodes) + throws UaException { + + Session session = context.getSession().orElse(null); + NodeId categoryId = context.getObjectId(); + + if (!policy.checkMutate(session, categoryId)) { + throw new UaException(StatusCodes.Bad_UserAccessDenied); + } + + errorCodes.set( + aliasManager.addAliasEntries( + categoryId, aliasNames, targetNodes, targetServers, targetReferenceType)); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasAuthorizationPolicy.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasAuthorizationPolicy.java new file mode 100644 index 0000000000..7e4450ac23 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasAuthorizationPolicy.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.eclipse.milo.opcua.sdk.server.Session; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.jspecify.annotations.Nullable; + +/** + * SPI deciding whether a session may search or mutate an alias category over the network. + * + *

The policy is consulted only for network Method calls ({@code FindAlias}, {@code + * FindAliasVerbose}, {@code AddAliasesToCategory}, {@code DeleteAliasesFromCategory}); the + * manager's programmatic API is trusted application code and bypasses it. A null session means an + * internal, trusted call. + * + *

The policy runs in addition to the Server's general access control (RolePermissions + * and {@code UserExecutable} checks), which remains the first gate. Implementations that need role + * information can use {@link Session#getRoleIds()}, which resolves through the configured {@code + * RoleMapper}. + * + *

A denial surfaces to the Client as {@code Bad_UserAccessDenied}. + */ +public interface AliasAuthorizationPolicy { + + /** + * The default policy: every session may search, no session may mutate. + * + *

Note that search results are not filtered by the Server's Browse access control: + * unlike the Browse service, {@code FindAlias} reads the AddressSpace directly, so + * RolePermissions and AccessRestrictions that hide Nodes from Browse do not hide the + * corresponding aliases or targets from search. Deployments that restrict Browse visibility per + * session should supply a policy that compensates via {@link #checkFind} and {@link + * #includeResult}. Mutation is deny-by-default; enabling network mutation requires an explicit + * policy grant in addition to enabling the mutation Methods. + */ + AliasAuthorizationPolicy ALLOW_FIND_DENY_MUTATE = + new AliasAuthorizationPolicy() { + @Override + public boolean checkFind(@Nullable Session session, NodeId categoryId) { + return true; + } + + @Override + public boolean checkMutate(@Nullable Session session, NodeId categoryId) { + return false; + } + }; + + /** + * Decide whether a session may call {@code FindAlias} or {@code FindAliasVerbose} on a category. + * + *

Consulted once per call, for the category the Method was invoked on; search authorization is + * all-or-nothing per call. The search then recurses into every subcategory of that category + * without consulting this method again, so denying find on a subcategory does not stop + * its aliases from appearing when an ancestor is searched — use {@link #includeResult} to filter + * individual aliases regardless of which category the search entered through. + * + * @param session the calling session, or null for an internal, trusted call. + * @param categoryId the NodeId of the category the Method was invoked on. + * @return {@code true} to allow the call, {@code false} to deny it with {@code + * Bad_UserAccessDenied}. + */ + boolean checkFind(@Nullable Session session, NodeId categoryId); + + /** + * Decide whether a session may call {@code AddAliasesToCategory} or {@code + * DeleteAliasesFromCategory} on a category. + * + * @param session the calling session, or null for an internal, trusted call. + * @param categoryId the NodeId of the category being mutated. + * @return {@code true} to allow the call, {@code false} to deny it with {@code + * Bad_UserAccessDenied}. + */ + boolean checkMutate(@Nullable Session session, NodeId categoryId); + + /** + * Decide whether a single alias may appear in a search result for a session. + * + *

Applied per matched alias after {@link #checkFind} has allowed the call. This is the + * only per-result filter: the Server's Browse access control (RolePermissions, + * AccessRestrictions) is not applied to search results, because the engine reads the AddressSpace + * directly rather than going through the Browse service. The default includes every result; + * override it in deployments where sessions must not learn of Nodes they cannot Browse. + * + * @param session the calling session, or null for an internal, trusted call. + * @param aliasNodeId the NodeId of the matched alias Node. + * @return {@code true} to include the alias in the result, {@code false} to omit it. + */ + default boolean includeResult(@Nullable Session session, NodeId aliasNodeId) { + return true; + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategory.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategory.java new file mode 100644 index 0000000000..8719562738 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategory.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; + +/** + * An immutable handle describing an alias category under management. + * + *

Returned when a category is added or adopted; use {@link #nodeId()} with the manager's + * programmatic API to add, delete, or look up aliases in the category. + * + * @param nodeId the NodeId of the category Node. + * @param browseName the BrowseName of the category Node. + * @param lastChangeEnabled whether the manager maintains a {@code LastChange} Property on this + * category. + * @param findAliasVerboseEnabled whether a {@code FindAliasVerbose} Method instance is bound on + * this category. + * @param configurationEnabled whether the {@code AddAliasesToCategory} and {@code + * DeleteAliasesFromCategory} Method instances are bound on this category. + */ +public record AliasCategory( + NodeId nodeId, + QualifiedName browseName, + boolean lastChangeEnabled, + boolean findAliasVerboseEnabled, + boolean configurationEnabled) {} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategoryConfig.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategoryConfig.java new file mode 100644 index 0000000000..60696e3ccb --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasCategoryConfig.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.function.Function; +import org.eclipse.milo.opcua.sdk.server.NodeManager; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; + +/** + * Configuration for an application-defined alias category. + * + *

The application controls where the category and its alias Nodes live and how their NodeIds are + * allocated; the framework materializes the category Node, its Method instances, and the parent + * linkage. + * + * @param categoryNodeId the NodeId of the category Node itself. + * @param parentCategoryId the NodeId of the category this category is organized by, e.g. {@code + * NodeIds.TagVariables} or another application category. + * @param browseName the BrowseName of the category Node. The name text is also used as the + * category's DisplayName. + * @param nodeManager the {@link NodeManager} the category Node, its Method Nodes, and the alias + * Nodes added to it are created in. + * @param aliasNodeIdFactory allocates the NodeId for an alias Node from its alias name. Called + * exactly once per alias creation — never for the category itself — so stateful factories are + * safe; must yield NodeIds that are unique within {@code nodeManager} and distinct from {@code + * categoryNodeId}. + * @param lastChangeEnabled whether the category gets a {@code LastChange} Property maintained by + * the manager. The Property is Optional per category; the root {@code Aliases} value is + * maintained regardless. + * @param findAliasVerboseEnabled whether a {@code FindAliasVerbose} Method instance is materialized + * and bound on the category. + * @param configurationEnabled whether the {@code AddAliasesToCategory} and {@code + * DeleteAliasesFromCategory} Methods are materialized and bound on the category. The Methods + * are network-callable only for sessions the {@link AliasAuthorizationPolicy} grants mutation + * to; the default policy denies every session, so enabling network mutation requires this flag + * and an explicit policy grant. + */ +public record AliasCategoryConfig( + NodeId categoryNodeId, + NodeId parentCategoryId, + QualifiedName browseName, + NodeManager nodeManager, + Function aliasNodeIdFactory, + boolean lastChangeEnabled, + boolean findAliasVerboseEnabled, + boolean configurationEnabled) {} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasLimits.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasLimits.java new file mode 100644 index 0000000000..965c754998 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasLimits.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +/** + * Limits applied to alias lookup and mutation calls. + * + *

All limits are enforced before any expensive work happens: patterns are length-checked before + * parsing, array arguments are length-checked before validation, and searches stop as soon as the + * result cap is exceeded. + * + * @param maxResults the maximum number of result entries a single {@code FindAlias} or {@code + * FindAliasVerbose} call may produce. Exceeding it fails the call with {@code + * Bad_ResponseTooLarge}; Part 17 defines no paging, so the Client must narrow its pattern. + * @param maxPatternLength the maximum length, in {@code char}s, of a search pattern. Longer + * patterns fail the call with {@code Bad_InvalidArgument}. + * @param maxOperationsPerCall the maximum number of entries in the array arguments of a single + * {@code AddAliasesToCategory} or {@code DeleteAliasesFromCategory} call. Longer arrays fail + * the call with {@code Bad_TooManyOperations}. Each entry locates aliases by scanning the + * category's directly organized members, so a call costs O(entries × category size) under the + * manager lock; servers with very large categories should size this limit accordingly. + */ +public record AliasLimits(int maxResults, int maxPatternLength, int maxOperationsPerCall) { + + /** + * Create an {@link AliasLimits}. + * + * @throws IllegalArgumentException if any limit is not positive. + */ + public AliasLimits { + if (maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive: " + maxResults); + } + if (maxPatternLength <= 0) { + throw new IllegalArgumentException("maxPatternLength must be positive: " + maxPatternLength); + } + if (maxOperationsPerCall <= 0) { + throw new IllegalArgumentException( + "maxOperationsPerCall must be positive: " + maxOperationsPerCall); + } + } + + /** + * The default limits: 1000 results, 512 pattern chars, 1000 operations per call. + * + * @return the default {@link AliasLimits}. + */ + public static AliasLimits defaults() { + return new AliasLimits(1000, 512, 1000); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManager.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManager.java new file mode 100644 index 0000000000..4916ecff98 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManager.java @@ -0,0 +1,2164 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.function.Predicate; +import org.eclipse.milo.opcua.sdk.core.Reference; +import org.eclipse.milo.opcua.sdk.server.AbstractLifecycle; +import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite; +import org.eclipse.milo.opcua.sdk.server.AddressSpaceFilter; +import org.eclipse.milo.opcua.sdk.server.ManagedAddressSpaceFragmentWithLifecycle; +import org.eclipse.milo.opcua.sdk.server.NodeManager; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.sdk.server.SimpleAddressSpaceFilter; +import org.eclipse.milo.opcua.sdk.server.items.DataItem; +import org.eclipse.milo.opcua.sdk.server.items.MonitoredItem; +import org.eclipse.milo.opcua.sdk.server.methods.AbstractMethodInvocationHandler; +import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryTypeNode; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameTypeNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext; +import org.eclipse.milo.opcua.sdk.server.nodes.instantiation.BrowsePath; +import org.eclipse.milo.opcua.sdk.server.nodes.instantiation.InstantiationRequest; +import org.eclipse.milo.opcua.sdk.server.nodes.instantiation.InstantiationResult; +import org.eclipse.milo.opcua.sdk.server.nodes.instantiation.NodeInstantiator; +import org.eclipse.milo.opcua.sdk.server.util.SubscriptionModel; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Opt-in server-side support for OPC UA Part 17 Alias Names: binds {@code FindAlias} (and + * optionally {@code FindAliasVerbose}, {@code AddAliasesToCategory}, and {@code + * DeleteAliasesFromCategory}) behavior onto the standard {@code Aliases}, {@code TagVariables}, and + * {@code Topics} Objects, manages application-defined alias categories, and maintains the {@code + * LastChange} version invariant. + * + *

Construct with the {@link OpcUaServer} and an {@link AliasManagerConfig}, then call {@link + * #startup()} after the server has started. Startup fails if a standard {@code FindAlias} + * Method already has an application-bound invocation handler, if a NodeId needed for a materialized + * Method Node is already in use, or if the configured {@link AliasVersionStore} cannot be read; a + * failed startup rolls back anything it had already applied, leaving no trace. On {@link + * #shutdown()} the manager unbinds its handlers, clears the executable flags it restored, removes + * the Method Nodes it materialized, and unregisters its AddressSpace fragment. + * + *

Node hosting

+ * + *

The manager owns an AddressSpace fragment (with its own NodeManager) that it registers with + * the server's AddressSpaceManager at startup and unregisters at shutdown; the fragment claims + * exactly the Nodes it contains, so services like Read and Browse route to them regardless of their + * NodeId's namespace. The hosting rule is: every Node the manager itself creates outside an + * application namespace — the Optional Method Nodes materialized on the standard Objects, and alias + * Nodes created by {@link #addAlias} in standard or adopted categories — lives in the manager's + * fragment, and therefore leaves the AddressSpace when the manager shuts down. Category and alias + * Nodes created through {@link #addCategory} live in the application-supplied {@link NodeManager}, + * whose owning namespace claims them; they remain in place at shutdown. + * + *

Categories are registered through {@link #addCategory} (materializes a new {@code + * AliasNameCategoryType} instance) or {@link #adoptCategory} (binds behavior onto an existing + * instance, e.g. one loaded from a NodeSet file). Aliases are created and removed through {@link + * #addAlias} and {@link #deleteAlias}; these programmatic paths are trusted application code and + * are not subject to the {@link AliasAuthorizationPolicy}. All mutation must flow through the + * manager (or be followed by {@link #touch}) for {@code LastChange} correctness; lookups read the + * live AddressSpace and need no registration. + * + *

Mutations are serialized by a manager-wide lock. Lookups take no lock and may observe a + * concurrent mutation partially applied — the same weak consistency Browse has. + */ +public final class AliasManager extends AbstractLifecycle { + + private static final List STANDARD_CATEGORY_IDS = + List.of(NodeIds.Aliases, NodeIds.TagVariables, NodeIds.Topics); + + private static final List STANDARD_FIND_ALIAS_NODE_IDS = + List.of(NodeIds.Aliases_FindAlias, NodeIds.TagVariables_FindAlias, NodeIds.Topics_FindAlias); + + private static final BrowsePath FIND_ALIAS_PATH = + BrowsePath.of(new QualifiedName(0, "FindAlias")); + + private static final BrowsePath FIND_ALIAS_VERBOSE_PATH = + BrowsePath.of(new QualifiedName(0, "FindAliasVerbose")); + + private static final BrowsePath ADD_ALIASES_TO_CATEGORY_PATH = + BrowsePath.of(new QualifiedName(0, "AddAliasesToCategory")); + + private static final BrowsePath DELETE_ALIASES_FROM_CATEGORY_PATH = + BrowsePath.of(new QualifiedName(0, "DeleteAliasesFromCategory")); + + private static final BrowsePath LAST_CHANGE_PATH = + BrowsePath.of(new QualifiedName(0, "LastChange")); + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final ReentrantLock lock = new ReentrantLock(); + + /** Managed categories, keyed by category NodeId. Guarded by {@link #lock}. */ + private final Map categories = new HashMap<>(); + + /** The standard {@code FindAlias} Nodes bound at startup. Guarded by {@link #lock}. */ + private final List boundStandardMethodNodes = new ArrayList<>(); + + /** Method Nodes materialized on the standard Objects at startup. Guarded by {@link #lock}. */ + private final List materializedMethodNodes = new ArrayList<>(); + + private final AliasSearchEngine searchEngine; + private final AliasVersionManager versionManager; + private final AliasTypes aliasTypes; + + /** Hosts the Nodes the manager creates outside application namespaces. */ + private final AliasFragment fragment; + + private final OpcUaServer server; + private final AliasManagerConfig config; + + /** + * Create an {@link AliasManager} for {@code server}. + * + *

The manager does nothing until {@link #startup()} is called, which must happen after the + * server itself has started (the standard namespace Nodes must exist). + * + * @param server the server whose AddressSpace the manager operates on. + * @param config the manager configuration. + */ + public AliasManager(OpcUaServer server, AliasManagerConfig config) { + this.server = server; + this.config = config; + + searchEngine = new AliasSearchEngine(server, config.getLimits(), config.getTargetOrdering()); + versionManager = new AliasVersionManager(server, config.getVersionStore()); + aliasTypes = new AliasTypes(server); + fragment = new AliasFragment(server); + } + + @Override + protected void onStartup() { + lock.lock(); + try { + // Validation phase: check everything that can foreseeably fail BEFORE mutating any state, + // so an expected failure (handler conflict, NodeId collision, unreadable version store) + // leaves no trace behind. + List findAliasNodes = resolveStandardFindAliasNodes(); + + for (UaMethodNode methodNode : findAliasNodes) { + MethodInvocationHandler handler = methodNode.getInvocationHandler(); + + if (!(handler instanceof MethodInvocationHandler.NotImplementedHandler)) { + throw new IllegalStateException( + "FindAlias Method %s already has an invocation handler: %s" + .formatted( + methodNode.getNodeId().toParseableString(), handler.getClass().getName())); + } + } + + List methodPlans = resolveStandardMethodPlans(); + + // Mutation phase: pre-validation makes failure here unexpected, but if anything throws + // anyway, roll back whatever was already applied before rethrowing. + boolean fragmentStarted = false; + try { + Map persisted; + try { + // A load failure throws before the version manager mutates any state; on success the + // persisted values are seeded in memory only — they are republished to the LastChange + // Properties at the end of startup, after every step that can fail. + persisted = versionManager.loadPersisted(); + } catch (UaException e) { + throw new IllegalStateException("failed to load persisted LastChange versions", e); + } + + fragment.startup(); + fragmentStarted = true; + + for (UaMethodNode methodNode : findAliasNodes) { + bindHandler( + methodNode, + new FindAliasMethodImpl(methodNode, searchEngine, config.getAuthorizationPolicy())); + + boundStandardMethodNodes.add(methodNode); + } + + for (MethodPlan methodPlan : methodPlans) { + materializeMethod(methodPlan); + } + + // The root Aliases Object's LastChange value is mandatory and persisted; when nothing was + // persisted yet, initialize it to a freshly computed VersionTime. Done last so the store + // is not written to until every other startup step has succeeded. + if (!persisted.containsKey(NodeIds.Aliases)) { + try { + versionManager.touch(NodeIds.Aliases); + } catch (UaException e) { + // A store that cannot save is as disqualifying as one that cannot load: continuing + // without a persisted root version would violate the §6.3.1 persistence contract. + throw new IllegalStateException("failed to persist initial LastChange version", e); + } + } + + // Publish the loaded versions to the LastChange Properties only now, after every step + // that can fail, so a failed startup leaves no trace in the AddressSpace. + versionManager.publishLoaded(); + } catch (RuntimeException | Error e) { + rollbackStartup(fragmentStarted); + throw e; + } + } finally { + lock.unlock(); + } + } + + /** + * Undo the state a partially completed startup applied: unbind handlers, delete materialized + * Nodes, and unregister the fragment. Best-effort; failures are logged, not propagated, so the + * original startup failure is the one the caller sees. + */ + private void rollbackStartup(boolean fragmentStarted) { + for (UaMethodNode methodNode : boundStandardMethodNodes) { + try { + unbindHandler(methodNode); + } catch (Exception e) { + logger.warn("Rollback failed to unbind handler: {}", methodNode.getNodeId(), e); + } + } + boundStandardMethodNodes.clear(); + + for (UaMethodNode methodNode : materializedMethodNodes) { + try { + methodNode.delete(); + } catch (Exception e) { + logger.warn("Rollback failed to delete Method Node: {}", methodNode.getNodeId(), e); + } + } + materializedMethodNodes.clear(); + + if (fragmentStarted) { + try { + fragment.shutdown(); + } catch (Exception e) { + logger.warn("Rollback failed to shut down AddressSpace fragment", e); + } + } + } + + @Override + protected void onShutdown() { + lock.lock(); + try { + for (UaMethodNode methodNode : boundStandardMethodNodes) { + unbindHandler(methodNode); + } + boundStandardMethodNodes.clear(); + + for (UaMethodNode methodNode : materializedMethodNodes) { + methodNode.delete(); + } + materializedMethodNodes.clear(); + + for (CategoryRecord record : categories.values()) { + for (UaMethodNode methodNode : record.boundMethodNodes()) { + unbindHandler(methodNode); + } + } + categories.clear(); + + // Last, mirroring startup order: the fragment (and the alias Nodes it still hosts) leaves + // the AddressSpace only after every handler is unbound and every materialized Node deleted. + fragment.shutdown(); + } finally { + lock.unlock(); + } + } + + /** + * Materialize a new {@code AliasNameCategoryType} instance and manage it. + * + *

The category Node's NodeId is the config's {@code categoryNodeId}; member Nodes get NodeIds + * derived from it, and the {@code aliasNodeIdFactory} is reserved for alias Nodes (it is never + * invoked here, so stateful factories are safe). The mandatory {@code FindAlias} Method is always + * bound; {@code FindAliasVerbose}, the {@code LastChange} Property, and the mutation Methods + * ({@code AddAliasesToCategory} / {@code DeleteAliasesFromCategory}) are materialized when + * enabled by the config. Mutation Methods are network-callable only for sessions the {@link + * AliasAuthorizationPolicy} grants mutation to; the default policy denies every session. + * + * @param categoryConfig describes the category to create. + * @return a handle for the created category. + * @throws UaException with {@code Bad_InvalidArgument} if the BrowseName has no name text; {@code + * Bad_NodeIdExists} if a category with the same NodeId is already managed; {@code + * Bad_InternalError} if the category's initial {@code LastChange} version cannot be persisted + * (the instantiation is undone, so nothing is created); or if instantiation fails (e.g. the + * parent does not exist or a NodeId collides). + * @throws IllegalStateException if the manager is not running. + */ + public AliasCategory addCategory(AliasCategoryConfig categoryConfig) throws UaException { + checkRunning(); + + lock.lock(); + try { + String name = categoryConfig.browseName().getName(); + if (name == null || name.isEmpty()) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, "category BrowseName has no name text"); + } + + NodeId categoryId = categoryConfig.categoryNodeId(); + + if (categories.containsKey(categoryId)) { + throw new UaException( + StatusCodes.Bad_NodeIdExists, + "category already managed: " + categoryId.toParseableString()); + } + + var boundMethodNodes = new ArrayList(); + + InstantiationRequest.Builder builder = + InstantiationRequest.of(AliasNameCategoryTypeNode.class, NodeIds.AliasNameCategoryType) + .nodeId(categoryId) + .browseName(categoryConfig.browseName()) + .displayName(LocalizedText.english(name)) + .parent(categoryConfig.parentCategoryId(), NodeIds.Organizes) + .target(categoryConfig.nodeManager()); + + bindMethodAt( + builder, + FIND_ALIAS_PATH, + boundMethodNodes, + methodNode -> + new FindAliasMethodImpl(methodNode, searchEngine, config.getAuthorizationPolicy())); + + if (categoryConfig.findAliasVerboseEnabled()) { + builder.includeOptional(FIND_ALIAS_VERBOSE_PATH); + bindMethodAt( + builder, + FIND_ALIAS_VERBOSE_PATH, + boundMethodNodes, + methodNode -> + new FindAliasVerboseMethodImpl( + methodNode, searchEngine, config.getAuthorizationPolicy())); + } + + if (categoryConfig.configurationEnabled()) { + builder.includeOptional(ADD_ALIASES_TO_CATEGORY_PATH); + bindMethodAt( + builder, + ADD_ALIASES_TO_CATEGORY_PATH, + boundMethodNodes, + methodNode -> + new AddAliasesToCategoryMethodImpl( + methodNode, this, config.getAuthorizationPolicy())); + + builder.includeOptional(DELETE_ALIASES_FROM_CATEGORY_PATH); + bindMethodAt( + builder, + DELETE_ALIASES_FROM_CATEGORY_PATH, + boundMethodNodes, + methodNode -> + new DeleteAliasesFromCategoryMethodImpl( + methodNode, this, config.getAuthorizationPolicy())); + } + + if (categoryConfig.lastChangeEnabled()) { + builder.includeOptional(LAST_CHANGE_PATH); + } + + InstantiationResult result = + new NodeInstantiator(server).instantiate(builder.build()); + + if (categoryConfig.lastChangeEnabled()) { + try { + // Initialize the new category's LastChange Property (and propagate to its ancestors). + versionManager.touch(categoryId); + } catch (UaException e) { + // The initial version could not be persisted, so the category must not come into + // existence: an unpersisted LastChange could repeat after a restart and leave Client + // caches undetectably stale. Undo the instantiation and report a clean failure. + for (UaMethodNode methodNode : boundMethodNodes) { + unbindHandler(methodNode); + } + result.deleteCreated(); + versionManager.remove(categoryId); + throw e; + } + } + + var category = + new AliasCategory( + categoryId, + categoryConfig.browseName(), + categoryConfig.lastChangeEnabled(), + categoryConfig.findAliasVerboseEnabled(), + categoryConfig.configurationEnabled()); + + categories.put( + categoryId, + new CategoryRecord( + categoryConfig.aliasNodeIdFactory(), + categoryConfig.nodeManager(), + boundMethodNodes, + result)); + + return category; + } finally { + lock.unlock(); + } + } + + /** + * Manage an existing {@code AliasNameCategoryType} instance, e.g. one loaded from a NodeSet file. + * + *

Adoption is non-recursive: exactly the given category is adopted, and subcategories found in + * the graph must be adopted individually if they should be bound too (they are still + * searched by any enclosing category's {@code FindAlias} regardless). The mandatory + * {@code FindAlias} Method is always bound. Pre-existing optional Method Nodes ({@code + * FindAliasVerbose}, {@code AddAliasesToCategory}, {@code DeleteAliasesFromCategory}) — the + * category's definition (e.g. its NodeSet) decided to offer them — are each bound only if still + * unbound; an instance that already has an invocation handler is left untouched, and a Method the + * category does not have is not created. Bound mutation Methods are network-callable only for + * sessions the {@link AliasAuthorizationPolicy} grants mutation to; the default policy denies + * every session. Alias Nodes created by {@link #addAlias} on an adopted category are hosted in + * the manager's own AddressSpace fragment, with NodeIds allocated in the config's Node namespace + * as {@code "/Alias/"}. + * + *

Binding publishes the {@code InputArguments}/{@code OutputArguments} Properties the handler + * declares onto the adopted Method Nodes. Shutdown unbinds the handlers but does not remove or + * restore those Properties — they remain, correctly describing the (again non-executable) + * Methods. + * + * @param categoryId the NodeId of the category to adopt. + * @return a handle for the adopted category; {@code lastChangeEnabled} reflects whether the + * category has a {@code LastChange} Property Node, {@code findAliasVerboseEnabled} reflects + * whether a {@code FindAliasVerbose} Method was bound, and {@code configurationEnabled} + * reflects whether at least one mutation Method was bound. + * @throws UaException with {@code Bad_NodeIdExists} if the category is already managed; {@code + * Bad_InvalidArgument} if it is a standard category (those are managed automatically); {@code + * Bad_NodeIdUnknown} if it does not exist or is not an {@code AliasNameCategoryType} + * instance; {@code Bad_NotFound} if it has no {@code FindAlias} Method Node; {@code + * Bad_InvalidState} if its {@code FindAlias} Method already has an invocation handler. + * @throws IllegalStateException if the manager is not running. + */ + public AliasCategory adoptCategory(NodeId categoryId) throws UaException { + checkRunning(); + + lock.lock(); + try { + if (categories.containsKey(categoryId)) { + throw new UaException( + StatusCodes.Bad_NodeIdExists, + "category already managed: " + categoryId.toParseableString()); + } + + if (STANDARD_CATEGORY_IDS.contains(categoryId)) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "standard categories are managed automatically: " + categoryId.toParseableString()); + } + + UaNode categoryNode = + server + .getAddressSpaceManager() + .getManagedNode(categoryId) + .orElseThrow( + () -> + new UaException( + StatusCodes.Bad_NodeIdUnknown, + "category not found: " + categoryId.toParseableString())); + + if (!aliasTypes.isAliasNameCategoryInstance(categoryId)) { + throw new UaException( + StatusCodes.Bad_NodeIdUnknown, + "not an AliasNameCategoryType instance: " + categoryId.toParseableString()); + } + + UaMethodNode findAliasNode = + findComponentMethodNode(categoryId, new QualifiedName(0, "FindAlias")); + + if (findAliasNode == null) { + throw new UaException( + StatusCodes.Bad_NotFound, + "category has no FindAlias Method: " + categoryId.toParseableString()); + } + + MethodInvocationHandler handler = findAliasNode.getInvocationHandler(); + if (!(handler instanceof MethodInvocationHandler.NotImplementedHandler)) { + throw new UaException( + StatusCodes.Bad_InvalidState, + "FindAlias Method %s already has an invocation handler: %s" + .formatted( + findAliasNode.getNodeId().toParseableString(), handler.getClass().getName())); + } + + bindHandler( + findAliasNode, + new FindAliasMethodImpl(findAliasNode, searchEngine, config.getAuthorizationPolicy())); + + var boundMethodNodes = new ArrayList(); + boundMethodNodes.add(findAliasNode); + + // Pre-existing optional Method Nodes were the category definition's decision to offer; + // give them behavior, but only where no other component already has. + boolean verboseBound = + bindOptionalMethodIfUnbound( + categoryId, + "FindAliasVerbose", + methodNode -> + new FindAliasVerboseMethodImpl( + methodNode, searchEngine, config.getAuthorizationPolicy()), + boundMethodNodes); + + boolean addBound = + bindOptionalMethodIfUnbound( + categoryId, + "AddAliasesToCategory", + methodNode -> + new AddAliasesToCategoryMethodImpl( + methodNode, this, config.getAuthorizationPolicy()), + boundMethodNodes); + + boolean deleteBound = + bindOptionalMethodIfUnbound( + categoryId, + "DeleteAliasesFromCategory", + methodNode -> + new DeleteAliasesFromCategoryMethodImpl( + methodNode, this, config.getAuthorizationPolicy()), + boundMethodNodes); + + boolean lastChangeEnabled = + categoryNode.getPropertyNode(AliasNameCategoryType.LAST_CHANGE).isPresent(); + + var category = + new AliasCategory( + categoryId, + categoryNode.getBrowseName(), + lastChangeEnabled, + verboseBound, + addBound || deleteBound); + + categories.put( + categoryId, + new CategoryRecord( + defaultAliasNodeIdFactory(categoryId), + fragment.getNodeManager(), + boundMethodNodes, + null)); + + return category; + } finally { + lock.unlock(); + } + } + + /** + * Stop managing a category: unbind its Method handlers and, if the category was created by {@link + * #addCategory}, delete the Nodes and References that creation added. + * + *

Alias Nodes added to the category after its creation are not deleted — their {@code + * Organizes} linkage to the category is removed so no References target the deleted NodeId, but + * the alias Nodes themselves survive. Delete them first with {@link #deleteAlias} if they should + * not outlive the category. + * + *

When the category Node is deleted, {@code LastChange} is bumped for its former ancestor + * categories (captured before deletion) and the category's own in-memory version entry is + * dropped. Removing an adopted category only unbinds handlers — the AddressSpace is + * unchanged, so no version is bumped. + * + * @param categoryId the NodeId of the category to remove. + * @throws UaException with {@code Bad_InvalidArgument} if {@code categoryId} is a standard + * category (those cannot be removed); {@code Bad_NodeIdUnknown} if it is not managed; {@code + * Bad_InternalError} if the ancestor categories' new {@code LastChange} versions cannot be + * persisted (the category is left untouched). + * @throws IllegalStateException if the manager is not running. + */ + public void removeCategory(NodeId categoryId) throws UaException { + checkRunning(); + + lock.lock(); + try { + if (STANDARD_CATEGORY_IDS.contains(categoryId)) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "standard categories cannot be removed: " + categoryId.toParseableString()); + } + + CategoryRecord record = categories.get(categoryId); + if (record == null) { + throw new UaException( + StatusCodes.Bad_NodeIdUnknown, + "category not managed: " + categoryId.toParseableString()); + } + + try { + if (record.instantiationResult() != null) { + // Capture the ancestor chain before deletion — once the category Node and its parent + // linkage are gone, the ancestors are no longer discoverable from it — and persist + // their new versions before any mutation: a failed save aborts the removal with + // nothing changed, leaving the category managed. Values saved before the failure + // stay pending and are published by the finally, per the prepare contract. + List ancestors = versionManager.getAncestorCategories(categoryId); + + if (!ancestors.isEmpty()) { + versionManager.prepare(ancestors); + } + } + + categories.remove(categoryId); + + for (UaMethodNode methodNode : record.boundMethodNodes()) { + unbindHandler(methodNode); + } + + if (record.instantiationResult() != null) { + // Detach aliases organized after creation: deleteCreated removes only what the + // instantiation recorded, so their Organizes linkage would otherwise dangle on the + // alias side, pointing at the deleted category — and silently reattach if a category + // with the same NodeId were created later. The alias Nodes themselves survive, per + // this method's contract. + for (NodeId aliasNodeId : findOrganizedAliases(categoryId)) { + server + .getAddressSpaceManager() + .getManagedNode(aliasNodeId) + .ifPresent(aliasNode -> removeFromCategory(aliasNode, categoryId)); + } + + record.instantiationResult().deleteCreated(); + + versionManager.remove(categoryId); + } + } finally { + versionManager.publishPending(); + } + } finally { + lock.unlock(); + } + } + + /** + * Create an alias in a category, or extend an existing alias of the same name with additional + * targets. + * + *

The category must be managed (via {@link #addCategory} or {@link #adoptCategory}) or one of + * the standard categories ({@code Aliases}, {@code TagVariables}, {@code Topics}). The alias + * Node's NodeId is allocated by the owning category's alias NodeId factory; for standard + * categories a default factory allocates {@code "/Alias/"} in the + * config's Node namespace, and the Node is hosted in the manager's own AddressSpace fragment (it + * leaves the AddressSpace when the manager shuts down). The alias Node's BrowseName uses its own + * NodeId's namespace index — lookup ignores the BrowseName namespace, so this is a presentation + * choice only. + * + *

Targets organized under the standard {@code TagVariables} and {@code Topics} Objects are + * constrained: when {@code categoryId} is {@code TagVariables} or {@code Topics}, or a category + * whose ancestor {@code Organizes} chain reaches one of them, each target Node must have the + * Variable NodeClass (TagVariables) or be an instance of {@code PublishedDataSetType} or a + * subtype (Topics). + * + *

Adding is idempotent: if an alias of the same name already exists in the category, missing + * target References are added and the existing NodeId is returned; if every given target is + * already associated, nothing changes. {@code LastChange} is bumped for the category (and its + * ancestors) whenever something actually changed. + * + *

The existing-alias lookup scans the category's directly organized members (the AddressSpace + * is the single source of truth; there is no name index), so each call costs O(category size) + * under the manager lock — bulk-loading N aliases one by one into the same category is O(N²). + * + *

This programmatic path is trusted application code: the {@link AliasAuthorizationPolicy} is + * not consulted. + * + * @param categoryId the NodeId of the organizing category. + * @param aliasName the alias name; becomes the alias Node's BrowseName text. + * @param targets the targets to associate; at least one, all local. + * @return the NodeId of the created (or pre-existing) alias Node. + * @throws UaException with {@code Bad_NodeIdUnknown} if the category is not managed-or-standard, + * does not exist, or a target Node does not exist; {@code Bad_InvalidArgument} if the alias + * name is empty, {@code targets} is empty, a target's ReferenceType is not {@code AliasFor} + * or a subtype, or a target violates the TagVariables/Topics NodeClass constraint; {@code + * Bad_NotSupported} for a remote target; {@code Bad_NodeIdExists} if the allocated alias + * NodeId is already in use by a different Node; {@code Bad_InternalError} if the affected + * categories' new {@code LastChange} versions cannot be persisted (the mutation is not + * applied). + * @throws IllegalStateException if the manager is not running. + */ + public NodeId addAlias(NodeId categoryId, String aliasName, List targets) + throws UaException { + + checkRunning(); + + lock.lock(); + try { + CategoryRecord record = resolveCategoryRecord(categoryId); + + if (aliasName.isEmpty()) { + throw new UaException(StatusCodes.Bad_InvalidArgument, "aliasName is empty"); + } + if (targets.isEmpty()) { + throw new UaException(StatusCodes.Bad_InvalidArgument, "at least one target is required"); + } + + TargetConstraint constraint = getTargetConstraint(categoryId); + + var resolvedTargets = new LinkedHashSet(); + for (AliasTarget target : targets) { + resolvedTargets.add(resolveTarget(target, constraint)); + } + + try { + return applyAddAlias(record, categoryId, aliasName, resolvedTargets); + } finally { + versionManager.publishPending(); + } + } finally { + lock.unlock(); + } + } + + /** + * Create an alias in a category, or extend an existing alias of the same name with the missing + * targets among {@code resolvedTargets} — the shared apply step of the programmatic {@link + * #addAlias} and the per-entry Method path, called with the lock held and every input already + * validated. The caller publishes the prepared versions in a {@code finally}. + * + *

The affected categories' new versions are prepared (persisted) as soon as a change is about + * to be applied — before the mutating call, so the LastChange bump both aborts the mutation when + * persistence fails and survives a mutation that throws partway through. A fully duplicate + * association changes nothing, prepares nothing, and is not an error (Part 17 §6.3.4 + * idempotency). + * + * @return the NodeId of the created (or pre-existing) alias Node. + */ + private NodeId applyAddAlias( + CategoryRecord record, + NodeId categoryId, + String aliasName, + Collection resolvedTargets) + throws UaException { + + NodeId existingAliasId = findAliasInCategory(categoryId, aliasName); + + if (existingAliasId != null) { + UaNode aliasNode = + server + .getAddressSpaceManager() + .getManagedNode(existingAliasId) + .orElseThrow( + () -> + new UaException( + StatusCodes.Bad_NodeIdUnknown, + "alias not found: " + existingAliasId.toParseableString())); + + // One Reference scan covers the whole batch; testing each target against a fresh scan + // would repeat the aggregated AddressSpace query per target. + Set existingAssociations = collectExistingAssociations(existingAliasId); + + boolean prepared = false; + + for (ResolvedTarget target : resolvedTargets) { + if (!existingAssociations.contains(target)) { + // A target change is observable from every category that organizes the alias, not + // just the one addressed, so all of them get a LastChange bump. Prepared BEFORE the + // mutation so a failed save aborts the change and a Reference add that throws + // partway through still gets its bumps published. + if (!prepared) { + var bumped = new ArrayList(); + bumped.add(categoryId); + bumped.addAll(getOrganizingCategories(existingAliasId)); + versionManager.prepare(bumped); + prepared = true; + } + + aliasNode.addReference( + new Reference( + existingAliasId, + target.referenceTypeId(), + target.nodeId().expanded(), + Reference.Direction.FORWARD)); + } + } + + return existingAliasId; + } + + NodeId aliasNodeId = record.aliasNodeIdFactory().apply(aliasName); + + if (server.getAddressSpaceManager().getManagedNode(aliasNodeId).isPresent()) { + throw new UaException( + StatusCodes.Bad_NodeIdExists, + "alias NodeId already in use: " + aliasNodeId.toParseableString()); + } + + var aliasNode = + new AliasNameTypeNode( + new ManagedNodeContext(server, record.nodeManager()), + aliasNodeId, + new QualifiedName(aliasNodeId.getNamespaceIndex(), aliasName), + // §6.2: the DisplayName is the BrowseName text "with an empty locale id and no + // other locale shall be provided" — hence not the single-argument constructor, + // which defaults the locale to "en". + new LocalizedText(null, aliasName), + LocalizedText.NULL_VALUE, + UInteger.valueOf(0), + UInteger.valueOf(0), + null, + null, + null); + + // Prepared BEFORE the mutations so a failed save aborts the creation with nothing applied, + // and the LastChange bump publishes even if the Node add or a Reference add throws partway + // through. + versionManager.prepare(List.of(categoryId)); + + record.nodeManager().addNode(aliasNode); + + try { + aliasNode.addReference( + new Reference( + aliasNodeId, + NodeIds.HasTypeDefinition, + NodeIds.AliasNameType.expanded(), + Reference.Direction.FORWARD)); + + aliasNode.addReference( + new Reference( + aliasNodeId, NodeIds.Organizes, categoryId.expanded(), Reference.Direction.INVERSE)); + + for (ResolvedTarget target : resolvedTargets) { + aliasNode.addReference( + new Reference( + aliasNodeId, + target.referenceTypeId(), + target.nodeId().expanded(), + Reference.Direction.FORWARD)); + } + } catch (RuntimeException e) { + // A partially wired alias Node — untyped, unlinked, or targetless — violates the Part 17 + // model and would be visible to searches forever, so a failure after addNode removes the + // Node again. Best-effort only: the removal runs through the same NodeManager that just + // threw, so its own failure is logged and the original failure propagates. + try { + aliasNode.delete(); + } catch (RuntimeException suppressed) { + e.addSuppressed(suppressed); + logger.error( + "Failed to remove partially created alias Node: {}", + aliasNodeId.toParseableString(), + suppressed); + } + throw e; + } + + return aliasNodeId; + } + + /** + * Delete an alias from a category, or remove individual target References from it. + * + *

With {@code targets == null} the alias Object is removed from this category; if no other + * category organizes it, the Object and all its References are deleted. With explicit targets, + * the matching {@code AliasFor}-or-subtype References are removed (removing a target that is not + * associated is a no-op); if the last such Reference is removed, the alias Object is deleted from + * every organizing category, because an alias without a target violates the Part 17 + * model. {@code LastChange} is bumped for every affected category. + * + *

This programmatic path is trusted application code: the {@link AliasAuthorizationPolicy} is + * not consulted. + * + * @param categoryId the NodeId of the organizing category. + * @param aliasName the alias name, matched against alias BrowseName text (namespace ignored). + * @param targets the targets to disassociate, or null to remove the alias from the category. + * @throws UaException with {@code Bad_NodeIdUnknown} if the category is not managed-or-standard + * or does not exist; {@code Bad_NotFound} if no alias of that name exists in the category; + * {@code Bad_InvalidArgument} if {@code targets} is non-null but empty, or an explicit + * target's ReferenceType is not {@code AliasFor} or a subtype; {@code Bad_InternalError} if + * the affected categories' new {@code LastChange} versions cannot be persisted (the mutation + * is not applied). + * @throws IllegalStateException if the manager is not running. + */ + public void deleteAlias(NodeId categoryId, String aliasName, @Nullable List targets) + throws UaException { + + checkRunning(); + + lock.lock(); + try { + resolveCategoryRecord(categoryId); + + NodeId aliasNodeId = findAliasInCategory(categoryId, aliasName); + if (aliasNodeId == null) { + throw new UaException( + StatusCodes.Bad_NotFound, + "alias \"%s\" not found in category %s" + .formatted(aliasName, categoryId.toParseableString())); + } + + UaNode aliasNode = + server + .getAddressSpaceManager() + .getManagedNode(aliasNodeId) + .orElseThrow( + () -> + new UaException( + StatusCodes.Bad_NotFound, + "alias not found: " + aliasNodeId.toParseableString())); + + if (targets == null) { + try { + // Persist the category's new version before mutating: a failed save aborts the + // removal with nothing changed. + versionManager.prepare(List.of(categoryId)); + + removeFromCategory(aliasNode, categoryId); + + if (getOrganizingCategories(aliasNodeId).isEmpty()) { + aliasNode.delete(); + } + } finally { + versionManager.publishPending(); + } + return; + } + + if (targets.isEmpty()) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "targets is empty; pass null to remove the alias from the category"); + } + + // Validate every explicit target's ReferenceType before removing anything: only alias + // linkage (AliasFor or a subtype) may be removed here. Without this check a caller-supplied + // ReferenceType like HasTypeDefinition would strip structural References and corrupt the + // Node. + for (AliasTarget target : targets) { + if (!aliasTypes.isAliasForOrSubtype(target.referenceTypeId())) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "ReferenceType is not AliasFor or a subtype: " + + target.referenceTypeId().toParseableString()); + } + } + + // Collect the matching References before removing anything, so the affected categories' + // new versions can be persisted before the first mutation. + var matching = new ArrayList(); + for (AliasTarget target : targets) { + NodeId targetNodeId = target.nodeId().toNodeId(server.getNamespaceTable()).orElse(null); + if (targetNodeId == null) { + // No local Node can match a non-local target; nothing to remove. + continue; + } + + matching.addAll( + findTargetReferences(aliasNodeId, targetNodeId, target.referenceTypeId()::equals)); + } + + if (matching.isEmpty()) { + return; + } + + try { + // A target change is observable from every category that organizes the alias, not just + // the one addressed, so all of them get a LastChange bump — persisted before the + // mutation so a failed save aborts it. + var bumped = new ArrayList(); + bumped.add(categoryId); + bumped.addAll(getOrganizingCategories(aliasNodeId)); + versionManager.prepare(bumped); + + for (Reference reference : matching) { + aliasNode.removeReference(reference); + } + + deleteIfTargetless(aliasNode); + } finally { + versionManager.publishPending(); + } + } finally { + lock.unlock(); + } + } + + /** + * Apply the entries of one {@code AddAliasesToCategory} Method call to a category, returning one + * StatusCode per entry (Part 17 §6.3.4). + * + *

Call-level validation failures fail the whole call before any entry is processed: null, + * empty, or non-parallel {@code AliasNames}/{@code TargetNodes} arrays, a non-empty {@code + * TargetServers} array of a different length, more entries than the configured + * operations-per-call limit, or a {@code TargetReferenceType} that is not a known ReferenceType + * in the {@code AliasFor} hierarchy. Everything else is reported per entry, and a failed entry + * does not affect any other entry. {@code LastChange} is bumped once per affected category after + * all entries are processed. Each entry locates its alias by scanning the category's directly + * organized members (see {@link AliasLimits#maxOperationsPerCall}), so a call costs O(entries + * × category size) under the manager lock. + * + *

Unlike the programmatic {@link #addAlias} — where a non-local {@link ExpandedNodeId} with a + * null server URI is rejected with {@code Bad_NotSupported} — this wire path follows §6.3.4's + * rule that "the ServerIndex in the ExpandedNodeId shall be ignored and the TargetServers Uri + * shall be used": when an entry's {@code TargetServers} element is null or empty the target is + * local, and any server reference carried by the wire {@code ExpandedNodeId} is dropped before + * resolution. A non-empty {@code TargetServers} element still marks the entry's target remote, + * which fails that entry with {@code Bad_NotSupported}. + * + *

Called by the network-facing Method handler after authorization; the {@link + * AliasAuthorizationPolicy} is not consulted here. + * + * @param categoryId the NodeId of the category the Method was called on. + * @param aliasNames the alias names, parallel to {@code targetNodes}. + * @param targetNodes the target Nodes, parallel to {@code aliasNames}. + * @param targetServers the target server URIs, either null/empty (all targets local) or parallel + * to {@code aliasNames}. + * @param targetReferenceType the ReferenceType for every created association; null (or a + * null-valued NodeId) defaults to {@code AliasFor}. + * @return one StatusCode per entry, parallel to the inputs. + * @throws UaException with {@code Bad_InvalidArgument} for invalid array shapes (including all + * arrays empty) or an invalid {@code TargetReferenceType}; {@code Bad_TooManyOperations} if + * the arrays exceed the operations-per-call limit; {@code Bad_NodeIdUnknown} if the category + * is not managed-or-standard or no longer exists; {@code Bad_InvalidState} if the manager is + * no longer running. + */ + StatusCode[] addAliasEntries( + NodeId categoryId, + String @Nullable [] aliasNames, + ExpandedNodeId @Nullable [] targetNodes, + String @Nullable [] targetServers, + @Nullable NodeId targetReferenceType) + throws UaException { + + // §6.3.4 requires AliasNames and TargetNodes to be parallel; absent arrays cannot satisfy it. + if (aliasNames == null || targetNodes == null || aliasNames.length != targetNodes.length) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, "AliasNames and TargetNodes must be parallel arrays"); + } + + // §6.3.4 Table 11 defines Bad_InvalidArgument for a call where "the size of the arrays for + // all arguments except TargetServers is not the same or if all arrays are empty" — so a + // zero-entry call is a call-level failure, not an empty success. + if (aliasNames.length == 0) { + throw new UaException(StatusCodes.Bad_InvalidArgument, "all arrays are empty"); + } + + // §6.3.4: a null or empty TargetServers array means every target is on the local server; + // a non-empty one must be parallel to the other arrays. + if (targetServers != null + && targetServers.length > 0 + && targetServers.length != aliasNames.length) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "TargetServers must be null, empty, or parallel to AliasNames"); + } + + // Part 17 §6.3.4 names only Bad_InvalidArgument and Bad_UserAccessDenied as call-level + // results, but Part 4 defines Bad_TooManyOperations for a request that specifies more + // operations than the Server supports — exactly this condition, and the code Milo's + // service-level operation limits already use — so it is preferred over the generic code. + if (aliasNames.length > config.getLimits().maxOperationsPerCall()) { + throw new UaException( + StatusCodes.Bad_TooManyOperations, + "%d entries exceed the maximum of %d" + .formatted(aliasNames.length, config.getLimits().maxOperationsPerCall())); + } + + // §6.3.4: a null TargetReferenceType defaults to AliasFor. Anything else must be a known + // ReferenceType in the AliasFor hierarchy — every alias must have an AliasFor-or-subtype + // Reference, so an invalid type fails the whole call, not individual entries. + NodeId referenceTypeId; + if (targetReferenceType == null || targetReferenceType.isNull()) { + referenceTypeId = NodeIds.AliasFor; + } else { + if (!server.getReferenceTypeTree().containsType(targetReferenceType)) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "unknown ReferenceType: " + targetReferenceType.toParseableString()); + } + if (!aliasTypes.isAliasForOrSubtype(targetReferenceType)) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "ReferenceType is not AliasFor or a subtype: " + + targetReferenceType.toParseableString()); + } + referenceTypeId = targetReferenceType; + } + + return processEntries( + categoryId, + aliasNames.length, + record -> { + TargetConstraint constraint = getTargetConstraint(categoryId); + + return i -> { + String targetServer = + targetServers != null && targetServers.length > 0 ? targetServers[i] : null; + + return addAliasEntry( + record, + categoryId, + constraint, + aliasNames[i], + targetNodes[i], + targetServer, + referenceTypeId); + }; + }); + } + + /** + * Apply one {@code AddAliasesToCategory} entry, mapping failures to the entry's StatusCode. + * + *

Validation happens before any mutation, so an entry that fails validation leaves the + * AddressSpace untouched. A duplicate of an existing association — whether pre-existing or + * created by an earlier entry of the same call — changes nothing and reports {@code Good} + * (§6.3.4: such entries "shall be ignored and no error shall be generated"). Application-supplied + * code (the category's alias NodeId factory, its {@link NodeManager}) can throw unchecked + * mid-entry, however; such a failure is confined to its entry as {@code Bad_InternalError}, and + * any category whose new version was prepared before the mutation still gets its LastChange + * published. + */ + private StatusCode addAliasEntry( + CategoryRecord record, + NodeId categoryId, + TargetConstraint constraint, + @Nullable String aliasName, + @Nullable ExpandedNodeId targetNode, + @Nullable String targetServer, + NodeId referenceTypeId) { + + try { + if (aliasName == null || aliasName.isEmpty()) { + // §6.3.4's per-entry table defines no code for an invalid alias name (its + // Bad_NodeIdInvalid is about the TargetNode), so the generic code is used. + return new StatusCode(StatusCodes.Bad_InvalidArgument); + } + + if (targetNode == null || targetNode.isNull()) { + // §6.3.4: "The syntax of the NodeId is not valid." + return new StatusCode(StatusCodes.Bad_NodeIdInvalid); + } + + if (targetServer != null && !targetServer.isEmpty()) { + // §6.3.4 defines Bad_NotSupported for servers that do not support aliases with remote + // targets, which this manager does not; Uncertain_ReferenceOutOfServer is reserved for + // servers that accept a remote target they cannot verify. + return new StatusCode(StatusCodes.Bad_NotSupported); + } + + // §6.3.4: "The ServerIndex in the ExpandedNodeId shall be ignored and the TargetServers + // Uri shall be used." The entry's TargetServers element is null or empty here, so the + // target is local and any server reference the wire ExpandedNodeId carries is dropped + // before resolution. (The programmatic addAlias keeps its stricter contract: a non-local + // ExpandedNodeId with a null serverUri is rejected with Bad_NotSupported.) + var localTargetNode = + new ExpandedNodeId( + ExpandedNodeId.ServerReference.of(0), + targetNode.namespace(), + targetNode.identifier()); + + // resolveTarget rejects unresolvable or missing local targets with Bad_NodeIdUnknown and + // TagVariables/Topics constraint violations with Bad_InvalidArgument — the §6.3.4 + // per-entry codes. + ResolvedTarget resolved = + resolveTarget(new AliasTarget(localTargetNode, null, referenceTypeId), constraint); + + applyAddAlias(record, categoryId, aliasName, List.of(resolved)); + + return StatusCode.GOOD; + } catch (UaException e) { + return e.getStatusCode(); + } catch (RuntimeException e) { + // Application-supplied code runs inside an entry (the category's aliasNodeIdFactory, its + // NodeManager) and can throw unchecked; confine the failure to this entry so the others + // stay independent (§6.3.4's per-entry contract). + logger.error("AddAliasesToCategory entry failed: alias \"{}\"", aliasName, e); + return new StatusCode(StatusCodes.Bad_InternalError); + } + } + + /** + * Apply the entries of one {@code DeleteAliasesFromCategory} Method call to a category, returning + * one StatusCode per entry (Part 17 §6.3.5). + * + *

Call-level validation failures fail the whole call before any entry is processed: a null + * {@code AliasNames} array, a non-null {@code TargetNodes} array of a different length, or more + * entries than the configured operations-per-call limit. §6.3.5 defines {@code TargetNodes} as a + * restriction on what is deleted and gives a null entry the meaning "all aliases with + * the provided name are deleted from the category"; a null {@code TargetNodes} array is + * read as every entry being null — no restriction anywhere — mirroring how §6.3.4 treats an + * absent {@code TargetServers} array. Per-entry failures affect only their own entry, and {@code + * LastChange} is bumped once per affected category after all entries are processed. Each entry + * locates its aliases by scanning the category's directly organized members (see {@link + * AliasLimits#maxOperationsPerCall}), so a call costs O(entries × category size) under the + * manager lock. + * + *

Called by the network-facing Method handler after authorization; the {@link + * AliasAuthorizationPolicy} is not consulted here. + * + * @param categoryId the NodeId of the category the Method was called on. + * @param aliasNames the alias names to delete. + * @param targetNodes the per-entry target restrictions, or null for no restrictions. + * @return one StatusCode per entry, parallel to the inputs. + * @throws UaException with {@code Bad_InvalidArgument} for invalid array shapes; {@code + * Bad_TooManyOperations} if the arrays exceed the operations-per-call limit; {@code + * Bad_NodeIdUnknown} if the category is not managed-or-standard or no longer exists; {@code + * Bad_InvalidState} if the manager is no longer running. + */ + StatusCode[] deleteAliasEntries( + NodeId categoryId, String @Nullable [] aliasNames, ExpandedNodeId @Nullable [] targetNodes) + throws UaException { + + if (aliasNames == null) { + throw new UaException(StatusCodes.Bad_InvalidArgument, "AliasNames is null"); + } + + if (targetNodes != null && targetNodes.length != aliasNames.length) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, "TargetNodes must be null or parallel to AliasNames"); + } + + // Deliberately NO empty-arrays failure here, unlike addAliasEntries: §6.3.5 Table 15's + // Bad_InvalidArgument covers only "an argument is of the wrong type or the size of the + // arrays for all arguments is not the same" — it lacks §6.3.4 Table 11's "or if all arrays + // are empty" clause — so a zero-entry Delete call succeeds vacuously with empty results. + + // See addAliasEntries for why Bad_TooManyOperations is preferred over Bad_InvalidArgument. + if (aliasNames.length > config.getLimits().maxOperationsPerCall()) { + throw new UaException( + StatusCodes.Bad_TooManyOperations, + "%d entries exceed the maximum of %d" + .formatted(aliasNames.length, config.getLimits().maxOperationsPerCall())); + } + + return processEntries( + categoryId, + aliasNames.length, + record -> + i -> + deleteAliasEntry( + categoryId, aliasNames[i], targetNodes != null ? targetNodes[i] : null)); + } + + /** + * The shared skeleton of the wire mutation paths ({@link #addAliasEntries}, {@link + * #deleteAliasEntries}): serialize under the manager lock, re-check the running state, resolve + * the category, apply one entry at a time, and publish the {@code LastChange} values the entries + * prepared. + * + *

Each entry persists the new versions of the categories it affects before its first + * mutation (a failed save fails the entry with nothing applied); the publish runs in a finally + * because entries applied before an escaping failure already mutated the AddressSpace, and + * §6.3.1's LastChange invariant must hold for them regardless. + */ + private StatusCode[] processEntries( + NodeId categoryId, int entryCount, Function entrySetup) + throws UaException { + + lock.lock(); + try { + // A Call dispatched before shutdown() can still be mid-dispatch when shutdown wins the + // lock first; by the time it gets here the fragment is unregistered, so mutating would + // create ghost state and persist spurious LastChange values. Fail with a defined code + // instead. + if (isNotRunning()) { + throw new UaException(StatusCodes.Bad_InvalidState, "AliasManager is not running"); + } + + EntryFunction entryFunction = entrySetup.apply(resolveCategoryRecord(categoryId)); + + var results = new StatusCode[entryCount]; + + try { + for (int i = 0; i < entryCount; i++) { + results[i] = entryFunction.apply(i); + } + } finally { + versionManager.publishPending(); + } + + return results; + } finally { + lock.unlock(); + } + } + + /** Applies one wire mutation entry, reporting its outcome as the entry's StatusCode. */ + @FunctionalInterface + private interface EntryFunction { + StatusCode apply(int index); + } + + /** + * Apply one {@code DeleteAliasesFromCategory} entry, mapping failures to the entry's StatusCode. + * + *

Unlike the programmatic {@link #deleteAlias}, which operates on one deterministically chosen + * alias, an entry applies to every alias Object of the given name directly organized by + * the category, per §6.3.5 ("all AliasNames with the provided name"). A null or null-valued + * target restriction removes those aliases from the category (an alias no other category + * organizes is deleted entirely); an explicit target removes the matching AliasFor-or-subtype + * References, and an alias whose last target Reference is removed is deleted from every + * organizing category, because an alias without a target violates the Part 17 model. Removing a + * target that is not associated changes nothing and reports {@code Good}; §6.3.5 reserves {@code + * Bad_NotFound} for an alias name the category does not contain. + * + *

Validation happens before any mutation, and reference removal through the manager's own + * fragment cannot partially fail, so an entry ordinarily either fully applies or leaves its state + * untouched (§6.3.5: "If all targets for an AliasNames array entry cannot be deleted, then none + * of the targets are deleted"). Aliases living in an application {@link NodeManager} can throw + * unchecked mid-entry, however; such a failure is confined to its entry as {@code + * Bad_InternalError}, and any category whose new version was prepared before the mutation still + * gets its LastChange published. + */ + private StatusCode deleteAliasEntry( + NodeId categoryId, @Nullable String aliasName, @Nullable ExpandedNodeId targetNode) { + + if (aliasName == null || aliasName.isEmpty()) { + // §6.3.5 defines no code for an invalid alias name; the generic code is used, matching + // the Add path. + return new StatusCode(StatusCodes.Bad_InvalidArgument); + } + + try { + List aliasNodeIds = findAliasesInCategory(categoryId, aliasName); + if (aliasNodeIds.isEmpty()) { + // §6.3.5: "The AliasName was not located." + return new StatusCode(StatusCodes.Bad_NotFound); + } + + if (targetNode == null || targetNode.isNull()) { + // §6.3.5: a null or empty TargetNodes entry deletes all aliases with the provided name + // from the category. + // + // Prepared BEFORE the mutations so a failed save fails the entry with nothing applied, + // and the category's LastChange bump publishes even if a removal throws partway + // through. + versionManager.prepare(List.of(categoryId)); + + for (NodeId aliasNodeId : aliasNodeIds) { + UaNode aliasNode = + server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElse(null); + if (aliasNode == null) { + continue; + } + + removeFromCategory(aliasNode, categoryId); + + if (getOrganizingCategories(aliasNodeId).isEmpty()) { + aliasNode.delete(); + } + } + + return StatusCode.GOOD; + } + + NodeId targetNodeId = targetNode.toNodeId(server.getNamespaceTable()).orElse(null); + if (targetNodeId == null) { + // No local Reference can match a non-local target restriction; nothing to remove. + return StatusCode.GOOD; + } + + for (NodeId aliasNodeId : aliasNodeIds) { + UaNode aliasNode = server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElse(null); + if (aliasNode == null) { + continue; + } + + // The Method carries no ReferenceType argument, so any AliasFor-or-subtype Reference to + // the target is removed. + List matching = + findTargetReferences(aliasNodeId, targetNodeId, aliasTypes::isAliasForOrSubtype); + + if (matching.isEmpty()) { + continue; + } + + // A target change is observable from every category that organizes the alias, not just + // the one addressed, so all of them get a LastChange bump. Prepared BEFORE the mutation + // so a failed save fails the entry before this alias is touched, and the bumps publish + // even if a Reference removal throws partway through. + var bumped = new ArrayList(); + bumped.add(categoryId); + bumped.addAll(getOrganizingCategories(aliasNodeId)); + versionManager.prepare(bumped); + + for (Reference reference : matching) { + aliasNode.removeReference(reference); + } + + deleteIfTargetless(aliasNode); + } + + return StatusCode.GOOD; + } catch (UaException e) { + // A failed LastChange save aborts the entry before its mutation is applied. + return e.getStatusCode(); + } catch (RuntimeException e) { + // Application-supplied code runs inside an entry (the aliases and their References can + // live in an application NodeManager) and can throw unchecked; confine the failure to + // this entry so the others stay independent (§6.3.5's per-entry contract). + logger.error("DeleteAliasesFromCategory entry failed: alias \"{}\"", aliasName, e); + return new StatusCode(StatusCodes.Bad_InternalError); + } + } + + /** + * Find aliases under {@code categoryId} whose name matches {@code pattern}. + * + *

Uses the same engine the {@code FindAlias} Method uses, but as a trusted programmatic call: + * the {@link AliasAuthorizationPolicy} is not consulted. No lock is taken; a lookup overlapping a + * mutation may observe it partially applied. + * + * @param categoryId the NodeId of the category to search from. + * @param pattern a Part 4 {@code Like} pattern matched against alias name text. + * @param referenceTypeFilter restricts targets to References of this type or a subtype; null + * means no restriction beyond {@code AliasFor} and its subtypes. + * @return the matching entries, ordered by alias name text then alias NodeId. + * @throws UaException see {@link AliasSearchEngine#findAlias(NodeId, String, NodeId)}. + * @throws IllegalStateException if the manager is not running. + */ + public List findAlias( + NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException { + + checkRunning(); + + return searchEngine.findAlias(categoryId, pattern, referenceTypeFilter); + } + + /** + * Find aliases under {@code categoryId} whose name matches {@code pattern}, with containing + * category and target server details. + * + *

Uses the same engine the {@code FindAliasVerbose} Method uses, but as a trusted programmatic + * call: the {@link AliasAuthorizationPolicy} is not consulted. No lock is taken; a lookup + * overlapping a mutation may observe it partially applied. + * + * @param categoryId the NodeId of the category to search from. + * @param pattern a Part 4 {@code Like} pattern matched against alias name text. + * @param referenceTypeFilter restricts targets to References of this type or a subtype; null + * means no restriction beyond {@code AliasFor} and its subtypes. + * @return the matching entries, ordered by alias name text then alias NodeId. + * @throws UaException see {@link AliasSearchEngine#findAliasVerbose(NodeId, String, NodeId)}. + * @throws IllegalStateException if the manager is not running. + */ + public List findAliasVerbose( + NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException { + + checkRunning(); + + return searchEngine.findAliasVerbose(categoryId, pattern, referenceTypeFilter); + } + + /** + * Bump the {@code LastChange} version of a category and its ancestor categories. + * + *

An escape hatch for applications that deliberately edited the alias hierarchy directly + * through a NodeManager: such edits are found by searches but bypass version maintenance, so they + * must be followed by a {@code touch} for Client caches to invalidate correctly. + * + * @param categoryId the NodeId of the category to bump. + * @throws UaException with {@code Bad_InternalError} if the new version cannot be persisted; a + * version is never published without being persisted first. + * @throws IllegalStateException if the manager is not running. + */ + public void touch(NodeId categoryId) throws UaException { + checkRunning(); + + lock.lock(); + try { + versionManager.touch(categoryId); + } finally { + lock.unlock(); + } + } + + private void checkRunning() { + if (isNotRunning()) { + throw new IllegalStateException("AliasManager is not running"); + } + } + + /** Resolve the standard {@code FindAlias} Nodes; a missing Node is logged and skipped. */ + private List resolveStandardFindAliasNodes() { + var methodNodes = new ArrayList(); + + for (NodeId nodeId : STANDARD_FIND_ALIAS_NODE_IDS) { + UaNode node = server.getAddressSpaceManager().getManagedNode(nodeId).orElse(null); + + if (node instanceof UaMethodNode methodNode) { + methodNodes.add(methodNode); + } else { + logger.warn("FindAlias UaMethodNode not found: {}", nodeId.toParseableString()); + } + } + + return methodNodes; + } + + /** + * Resolve the Optional Method Nodes to materialize on the standard Objects — {@code + * FindAliasVerbose} when verbose lookup is enabled, {@code AddAliasesToCategory} and {@code + * DeleteAliasesFromCategory} when configuration is enabled — and verify their NodeIds are free; a + * missing Object is logged and skipped, a NodeId collision fails startup. + * + *

Pure validation: no state is mutated, so a throw here leaves no trace. + */ + private List resolveStandardMethodPlans() { + var plans = new ArrayList(); + + for (NodeId objectId : STANDARD_CATEGORY_IDS) { + UaNode objectNode = server.getAddressSpaceManager().getManagedNode(objectId).orElse(null); + if (objectNode == null) { + logger.warn( + "Cannot materialize Optional Methods; Object not found: {}", + objectId.toParseableString()); + continue; + } + + // The ns=0 BrowseName of the standard Object seeds the materialized Method NodeIds, e.g. + // "Aliases/FindAliasVerbose". + String objectName = objectNode.getBrowseName().name(); + if (objectName == null) { + logger.warn( + "Cannot materialize Optional Methods; Object has no BrowseName text: {}", + objectId.toParseableString()); + continue; + } + + if (config.isFindAliasVerboseEnabled()) { + plans.add( + newMethodPlan( + objectNode, + objectName, + "FindAliasVerbose", + methodNode -> + new FindAliasVerboseMethodImpl( + methodNode, searchEngine, config.getAuthorizationPolicy()))); + } + + if (config.isConfigurationEnabled()) { + plans.add( + newMethodPlan( + objectNode, + objectName, + "AddAliasesToCategory", + methodNode -> + new AddAliasesToCategoryMethodImpl( + methodNode, this, config.getAuthorizationPolicy()))); + + plans.add( + newMethodPlan( + objectNode, + objectName, + "DeleteAliasesFromCategory", + methodNode -> + new DeleteAliasesFromCategoryMethodImpl( + methodNode, this, config.getAuthorizationPolicy()))); + } + } + + return plans; + } + + /** + * Build the plan for one Method Node to materialize, verifying its NodeId — {@code + * "/"} in the configured Node namespace — is free. + */ + private MethodPlan newMethodPlan( + UaNode objectNode, + String objectName, + String methodName, + Function handlerFactory) { + + var methodNodeId = new NodeId(config.getNodeNamespaceIndex(), objectName + "/" + methodName); + + if (server.getAddressSpaceManager().getManagedNode(methodNodeId).isPresent()) { + throw new IllegalStateException("NodeId already in use: " + methodNodeId.toParseableString()); + } + + return new MethodPlan( + objectNode, methodNodeId, new QualifiedName(0, methodName), handlerFactory); + } + + /** + * Materialize the Method Node described by {@code plan} as a component of its standard Object, + * bind a handler, and record it for removal at shutdown. + * + *

The Node lives in the manager's own fragment; its NodeId is in the configured Method-Node + * namespace and its BrowseName is the ns=0 name of the standard type member it instantiates. + */ + private void materializeMethod(MethodPlan plan) { + NodeId methodNodeId = plan.methodNodeId(); + QualifiedName browseName = plan.browseName(); + + var methodNode = + new UaMethodNode( + fragment.getNodeContext(), + methodNodeId, + browseName, + LocalizedText.english(browseName.name()), + LocalizedText.NULL_VALUE, + UInteger.valueOf(0), + UInteger.valueOf(0), + true, + true); + + fragment.getNodeManager().addNode(methodNode); + + methodNode.addReference( + new Reference( + methodNodeId, + NodeIds.HasComponent, + plan.objectNode().getNodeId().expanded(), + Reference.Direction.INVERSE)); + + bindHandler(methodNode, plan.handlerFactory().apply(methodNode)); + + materializedMethodNodes.add(methodNode); + } + + /** + * Add a {@code bindMethod} step to {@code builder}: bind the handler the factory produces and + * record the bound Node in {@code boundMethodNodes} for unbinding later. + */ + private void bindMethodAt( + InstantiationRequest.Builder builder, + BrowsePath path, + List boundMethodNodes, + Function handlerFactory) { + + builder.bindMethod( + path, + methodNode -> { + bindHandler(methodNode, handlerFactory.apply(methodNode)); + boundMethodNodes.add(methodNode); + }); + } + + /** + * Bind {@code handler} to {@code methodNode}, set the argument Properties from the handler's + * definitions, and restore the executable flags. + */ + private static void bindHandler( + UaMethodNode methodNode, AbstractMethodInvocationHandler handler) { + + methodNode.bindInvocationHandler(handler); + + methodNode.setExecutable(true); + methodNode.setUserExecutable(true); + } + + /** + * Reset {@code methodNode} to its unbound state: no handler, not executable. + * + *

Deliberately not the full inverse of {@link #bindHandler}: argument Properties the bind + * published stay in place, still correctly describing the now non-executable Method. + */ + private static void unbindHandler(UaMethodNode methodNode) { + methodNode.setInvocationHandler(MethodInvocationHandler.NOT_IMPLEMENTED); + methodNode.setExecutable(false); + methodNode.setUserExecutable(false); + } + + /** + * Resolve the record of a managed or standard category, verifying the category Node exists. + * + *

Standard categories are usable without registration; their record hosts alias Nodes in the + * manager's own fragment and uses the default alias NodeId factory. + */ + private CategoryRecord resolveCategoryRecord(NodeId categoryId) throws UaException { + CategoryRecord record = categories.get(categoryId); + + if (record == null && !STANDARD_CATEGORY_IDS.contains(categoryId)) { + throw new UaException( + StatusCodes.Bad_NodeIdUnknown, "category not managed: " + categoryId.toParseableString()); + } + + if (server.getAddressSpaceManager().getManagedNode(categoryId).isEmpty()) { + throw new UaException( + StatusCodes.Bad_NodeIdUnknown, "category not found: " + categoryId.toParseableString()); + } + + if (record != null) { + return record; + } + + return new CategoryRecord( + defaultAliasNodeIdFactory(categoryId), fragment.getNodeManager(), List.of(), null); + } + + /** + * The alias NodeId factory used for standard and adopted categories, which have no + * application-supplied factory: {@code "/Alias/"} in the configured + * Node namespace. + */ + private Function defaultAliasNodeIdFactory(NodeId categoryId) { + return aliasName -> + new NodeId( + config.getNodeNamespaceIndex(), categoryId.toParseableString() + "/Alias/" + aliasName); + } + + /** + * The target constraint the organizing category imposes: Variable NodeClass under {@code + * TagVariables}, {@code PublishedDataSetType} instances under {@code Topics}, none elsewhere. + * + *

A constraint applies when {@code categoryId} is the standard Object or its ancestor + * {@code Organizes} chain (through {@code AliasNameCategoryType} instances) reaches it. A + * category reachable from both — a degenerate multi-parent arrangement — gets both constraints, + * which no target can satisfy. + */ + private TargetConstraint getTargetConstraint(NodeId categoryId) { + boolean tagVariable = NodeIds.TagVariables.equals(categoryId); + boolean topic = NodeIds.Topics.equals(categoryId); + + if (!tagVariable && !topic) { + List ancestors = versionManager.getAncestorCategories(categoryId); + tagVariable = ancestors.contains(NodeIds.TagVariables); + topic = ancestors.contains(NodeIds.Topics); + } + + return new TargetConstraint(tagVariable, topic); + } + + /** + * Validate an {@link AliasTarget} and resolve its NodeId: the ReferenceType must be {@code + * AliasFor} or a subtype, the target must be local and resolvable against the namespace table, + * the target Node must exist, and the organizing category's NodeClass/type-definition constraint + * (if any) must hold. + */ + private ResolvedTarget resolveTarget(AliasTarget target, TargetConstraint constraint) + throws UaException { + + if (!aliasTypes.isAliasForOrSubtype(target.referenceTypeId())) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "ReferenceType is not AliasFor or a subtype: " + + target.referenceTypeId().toParseableString()); + } + + if (!target.isLocal() || !target.nodeId().isLocal()) { + throw new UaException( + StatusCodes.Bad_NotSupported, + "remote targets are not supported: " + target.nodeId().toParseableString()); + } + + NodeId targetNodeId = + target + .nodeId() + .toNodeId(server.getNamespaceTable()) + .orElseThrow( + () -> + new UaException( + StatusCodes.Bad_NodeIdUnknown, + "target not resolvable: " + target.nodeId().toParseableString())); + + UaNode targetNode = + server + .getAddressSpaceManager() + .getManagedNode(targetNodeId) + .orElseThrow( + () -> + new UaException( + StatusCodes.Bad_NodeIdUnknown, + "target not found: " + targetNodeId.toParseableString())); + + if (constraint.requireVariable() && targetNode.getNodeClass() != NodeClass.Variable) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "aliases organized under TagVariables must target Variable Nodes; %s has NodeClass %s" + .formatted(targetNodeId.toParseableString(), targetNode.getNodeClass())); + } + + if (constraint.requirePublishedDataSet()) { + NodeId typeDefinitionId = aliasTypes.getTypeDefinitionId(targetNodeId); + + boolean isPublishedDataSet = + typeDefinitionId != null + && (NodeIds.PublishedDataSetType.equals(typeDefinitionId) + || server + .getObjectTypeTree() + .isSubtypeOf(typeDefinitionId, NodeIds.PublishedDataSetType)); + + if (!isPublishedDataSet) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "aliases organized under Topics must target PublishedDataSetType instances: " + + targetNodeId.toParseableString()); + } + } + + return new ResolvedTarget(targetNodeId, target.referenceTypeId()); + } + + /** + * Find the alias Object named {@code aliasName} directly organized by {@code categoryId}, + * matching on BrowseName text alone (namespace index ignored). When several same-named alias + * Objects exist, the one with the smallest parseable NodeId is chosen, deterministically. + */ + private @Nullable NodeId findAliasInCategory(NodeId categoryId, String aliasName) { + return findAliasesInCategory(categoryId, aliasName).stream() + .min(Comparator.comparing(NodeId::toParseableString)) + .orElse(null); + } + + /** Find every alias Object directly organized by {@code categoryId}, regardless of name. */ + private List findOrganizedAliases(NodeId categoryId) { + List organizes = + server + .getAddressSpaceManager() + .getManagedReferences(categoryId, Reference.ORGANIZES_PREDICATE); + + var found = new ArrayList(); + for (Reference reference : organizes) { + reference + .getTargetNodeId() + .toNodeId(server.getNamespaceTable()) + .filter(aliasTypes::isAliasNameInstance) + .ifPresent(found::add); + } + return found; + } + + /** + * Find every alias Object named {@code aliasName} directly organized by {@code categoryId}, + * matching on BrowseName text alone (namespace index ignored). + */ + private List findAliasesInCategory(NodeId categoryId, String aliasName) { + List organizes = + server + .getAddressSpaceManager() + .getManagedReferences(categoryId, Reference.ORGANIZES_PREDICATE); + + var found = new ArrayList(); + for (Reference reference : organizes) { + NodeId organizedId = + reference.getTargetNodeId().toNodeId(server.getNamespaceTable()).orElse(null); + if (organizedId == null) { + continue; + } + + // Name before type: the name comparison is an in-memory check, while the type test costs + // another Reference query plus a type-tree walk, and most organized Nodes won't match. + UaNode node = server.getAddressSpaceManager().getManagedNode(organizedId).orElse(null); + if (node == null || !aliasName.equals(node.getBrowseName().name())) { + continue; + } + + if (aliasTypes.isAliasNameInstance(organizedId)) { + found.add(organizedId); + } + } + return found; + } + + /** + * The (target, ReferenceType) pairs of the alias's existing forward References, for batch + * duplicate checks against {@link ResolvedTarget}s. + */ + private Set collectExistingAssociations(NodeId aliasNodeId) { + var associations = new HashSet(); + + List references = + server.getAddressSpaceManager().getManagedReferences(aliasNodeId, Reference::isForward); + + for (Reference reference : references) { + reference + .getTargetNodeId() + .toNodeId(server.getNamespaceTable()) + .ifPresent( + targetId -> + associations.add(new ResolvedTarget(targetId, reference.getReferenceTypeId()))); + } + + return associations; + } + + /** + * The alias's forward References to {@code targetNodeId} whose ReferenceType passes {@code + * refTypeOk}, resolving each Reference's target against the namespace table. + */ + private List findTargetReferences( + NodeId aliasNodeId, NodeId targetNodeId, Predicate refTypeOk) { + + return server + .getAddressSpaceManager() + .getManagedReferences( + aliasNodeId, + reference -> + reference.isForward() + && refTypeOk.test(reference.getReferenceTypeId()) + && reference + .getTargetNodeId() + .toNodeId(server.getNamespaceTable()) + .map(targetNodeId::equals) + .orElse(false)); + } + + /** + * Delete the alias Object if its last target Reference is gone — an alias without a target + * violates the Part 17 model, so it is deleted from every category that organizes it. The + * organizing categories' new versions are prepared (persisted) before deletion, so a + * failed save aborts the deletion and a deletion that throws partway through still gets its + * {@code LastChange} bumps published. + * + * @return {@code true} if the alias was targetless and deleted. + */ + private boolean deleteIfTargetless(UaNode aliasNode) throws UaException { + NodeId aliasNodeId = aliasNode.getNodeId(); + + if (!collectRemainingTargets(aliasNodeId).isEmpty()) { + return false; + } + + versionManager.prepare(getOrganizingCategories(aliasNodeId)); + + aliasNode.delete(); + + return true; + } + + /** The forward {@code AliasFor}-or-subtype References of an alias Node. */ + private List collectRemainingTargets(NodeId aliasNodeId) { + return server + .getAddressSpaceManager() + .getManagedReferences( + aliasNodeId, + reference -> + reference.isForward() + && aliasTypes.isAliasForOrSubtype(reference.getReferenceTypeId())); + } + + /** + * The categories that organize {@code aliasNodeId}, resolved via inverse Organizes References + * aggregated across every registered NodeManager. + * + *

Limitation: a category-side-only linkage — a forward {@code Organizes} Reference recorded + * without its inverse, possible when References are added out-of-band, e.g. by a NodeSet loader + * that does not write inverses — is invisible from the alias side and is missed here, so such a + * category is not treated as organizing the alias. + */ + private List getOrganizingCategories(NodeId aliasNodeId) { + List references = + server + .getAddressSpaceManager() + .getManagedReferences(aliasNodeId, Reference.ORGANIZED_BY_PREDICATE); + + var categoryIds = new ArrayList(); + for (Reference reference : references) { + reference + .getTargetNodeId() + .toNodeId(server.getNamespaceTable()) + .filter(aliasTypes::isAliasNameCategoryInstance) + .ifPresent(categoryIds::add); + } + return categoryIds; + } + + /** + * Remove the Organizes linkage between {@code categoryId} and the alias, from both Nodes' + * NodeManagers — the linkage may have been stored by either side. + */ + private void removeFromCategory(UaNode aliasNode, NodeId categoryId) { + NodeId aliasNodeId = aliasNode.getNodeId(); + + aliasNode.removeReference( + new Reference( + aliasNodeId, NodeIds.Organizes, categoryId.expanded(), Reference.Direction.INVERSE)); + + server + .getAddressSpaceManager() + .getManagedNode(categoryId) + .ifPresent( + categoryNode -> + categoryNode.removeReference( + new Reference( + categoryId, + NodeIds.Organizes, + aliasNodeId.expanded(), + Reference.Direction.FORWARD))); + } + + /** + * Find a Method component of {@code nodeId} by BrowseName. + * + *

Reference-based rather than typed-node-based so that NodeSet-loaded plain Object Nodes + * qualify. + */ + private @Nullable UaMethodNode findComponentMethodNode(NodeId nodeId, QualifiedName browseName) { + List references = + server + .getAddressSpaceManager() + .getManagedReferences(nodeId, Reference.HAS_COMPONENT_PREDICATE); + + for (Reference reference : references) { + UaNode node = + reference + .getTargetNodeId() + .toNodeId(server.getNamespaceTable()) + .flatMap(id -> server.getAddressSpaceManager().getManagedNode(id)) + .orElse(null); + + if (node instanceof UaMethodNode methodNode && browseName.equals(node.getBrowseName())) { + return methodNode; + } + } + return null; + } + + /** + * Bind a handler on the optional {@code methodName} component of an adopted category, if that + * Method Node exists and is still unbound; a missing or already-bound Node is left untouched. + * + * @return {@code true} if a handler was bound. + */ + private boolean bindOptionalMethodIfUnbound( + NodeId categoryId, + String methodName, + Function handlerFactory, + List boundMethodNodes) { + + UaMethodNode methodNode = findComponentMethodNode(categoryId, new QualifiedName(0, methodName)); + + if (methodNode == null) { + return false; + } + + if (!(methodNode.getInvocationHandler() + instanceof MethodInvocationHandler.NotImplementedHandler)) { + return false; + } + + bindHandler(methodNode, handlerFactory.apply(methodNode)); + boundMethodNodes.add(methodNode); + + return true; + } + + /** A validated, locally resolved alias target. */ + private record ResolvedTarget(NodeId nodeId, NodeId referenceTypeId) {} + + /** + * The NodeClass/type-definition constraint an organizing category imposes on alias targets; see + * {@link #getTargetConstraint}. + */ + private record TargetConstraint(boolean requireVariable, boolean requirePublishedDataSet) {} + + /** An Optional Method Node to materialize on a standard Object, with its handler recipe. */ + private record MethodPlan( + UaNode objectNode, + NodeId methodNodeId, + QualifiedName browseName, + Function handlerFactory) {} + + /** + * Everything the manager tracks per managed category: where its alias Nodes live and how their + * NodeIds are allocated, the Method Nodes whose handlers must be reset, and — for categories the + * manager created — the instantiation journal used to delete them again. + */ + private record CategoryRecord( + Function aliasNodeIdFactory, + NodeManager nodeManager, + List boundMethodNodes, + @Nullable InstantiationResult instantiationResult) {} + + /** A minimal {@link UaNodeContext} binding manually created Nodes to their NodeManager. */ + private record ManagedNodeContext(OpcUaServer server, NodeManager nodeManager) + implements UaNodeContext { + + @Override + public OpcUaServer getServer() { + return server; + } + + @Override + public NodeManager getNodeManager() { + return nodeManager; + } + } + + /** + * The manager's AddressSpace fragment: hosts the Nodes the manager creates outside application + * namespaces (materialized Method Nodes, alias Nodes in standard and adopted categories) and + * claims exactly the Nodes its NodeManager contains, so service operations route to them + * regardless of NodeId namespace. + * + *

Startup registers the fragment and its NodeManager with the server's AddressSpaceManager; + * shutdown unregisters both. A SubscriptionModel provides sampling for MonitoredItems created on + * hosted Nodes. + * + *

The fragment registers itself first in the composite: service routing picks the + * first registered AddressSpace whose filter matches, and hosted Nodes have NodeIds allocated in + * an application namespace, so an application Namespace registered earlier (whose filter matches + * its entire namespace index) would otherwise shadow them. Because the filter matches exactly the + * Nodes the fragment contains, registering first diverts no other traffic. + */ + private static final class AliasFragment extends ManagedAddressSpaceFragmentWithLifecycle { + + private final AddressSpaceFilter filter = + SimpleAddressSpaceFilter.create(getNodeManager()::containsNode); + + private final SubscriptionModel subscriptionModel; + + AliasFragment(OpcUaServer server) { + super(server); + + subscriptionModel = new SubscriptionModel(server, this); + + getLifecycleManager().addLifecycle(subscriptionModel); + } + + @Override + public AddressSpaceFilter getFilter() { + return filter; + } + + @Override + protected void registerWithComposite(AddressSpaceComposite composite) { + composite.registerFirst(this); + } + + @Override + public void onDataItemsCreated(List dataItems) { + subscriptionModel.onDataItemsCreated(dataItems); + } + + @Override + public void onDataItemsModified(List dataItems) { + subscriptionModel.onDataItemsModified(dataItems); + } + + @Override + public void onDataItemsDeleted(List dataItems) { + subscriptionModel.onDataItemsDeleted(dataItems); + } + + @Override + public void onMonitoringModeChanged(List monitoredItems) { + subscriptionModel.onMonitoringModeChanged(monitoredItems); + } + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerConfig.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerConfig.java new file mode 100644 index 0000000000..a0e2cfd9d0 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasManagerConfig.java @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.Comparator; +import java.util.Objects; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort; + +/** + * Configuration for an alias manager. + * + *

Every setting has a usable default, so {@code AliasManagerConfig.builder().build()} yields a + * working configuration — but note that the default {@link AliasVersionStore} is in-memory only and + * does not satisfy the Part 17 persistence requirement; production applications should supply their + * own via {@link Builder#versionStore(AliasVersionStore)}. + */ +public interface AliasManagerConfig { + + /** + * The store used to persist category {@code LastChange} versions across restarts. + * + * @return the configured {@link AliasVersionStore}. + */ + AliasVersionStore getVersionStore(); + + /** + * The policy deciding whether sessions may search or mutate categories over the network. + * + * @return the configured {@link AliasAuthorizationPolicy}. + */ + AliasAuthorizationPolicy getAuthorizationPolicy(); + + /** + * The limits applied to alias lookup and mutation calls. + * + * @return the configured {@link AliasLimits}. + */ + AliasLimits getLimits(); + + /** + * The ordering applied to the targets within each result entry, expressing the application's + * target preference; Clients use the first usable entry. + * + *

The default is {@link AliasTarget#DEFAULT_ORDERING}: local targets before remote ones, then + * by the target NodeId's parseable string form, so results are deterministic. + * + * @return the configured target {@link Comparator}. + */ + Comparator getTargetOrdering(); + + /** + * The namespace index used to allocate NodeIds for the Nodes the manager itself creates: the + * Method Nodes it materializes on the standard ns=0 alias Objects (the Optional Methods the + * standard NodeSet does not define), and the alias Nodes produced by the default alias NodeId + * factory of standard and adopted categories. + * + *

A Node's NodeId namespace is independent of its parent's; this only controls where the + * manager-allocated identifiers live. Categories added via {@link + * AliasManager#addCategory(AliasCategoryConfig)} supply their own factory and are unaffected. + * + * @return the configured namespace index. + */ + UShort getNodeNamespaceIndex(); + + /** + * Whether {@code FindAliasVerbose} Method instances are materialized and bound on the standard + * {@code Aliases}, {@code TagVariables}, and {@code Topics} Objects. + * + * @return {@code true} if {@code FindAliasVerbose} is enabled on the standard categories. + */ + boolean isFindAliasVerboseEnabled(); + + /** + * Whether the {@code AddAliasesToCategory} and {@code DeleteAliasesFromCategory} Methods are + * materialized and bound on the standard {@code Aliases}, {@code TagVariables}, and {@code + * Topics} Objects. + * + *

Materialized Methods are network-callable only for sessions the {@link + * AliasAuthorizationPolicy} grants mutation to; the default policy denies every session, so + * enabling network mutation requires this flag and an explicit policy grant. + * + * @return {@code true} if the mutation Methods are enabled on the standard categories. + */ + boolean isConfigurationEnabled(); + + /** + * Create a new {@link Builder} with every setting at its default. + * + * @return a new {@link Builder}. + */ + static Builder builder() { + return new Builder(); + } + + /** Builds immutable {@link AliasManagerConfig} instances. */ + final class Builder { + + private AliasVersionStore versionStore = new InMemoryAliasVersionStore(); + private AliasAuthorizationPolicy authorizationPolicy = + AliasAuthorizationPolicy.ALLOW_FIND_DENY_MUTATE; + private AliasLimits limits = AliasLimits.defaults(); + private Comparator targetOrdering = AliasTarget.DEFAULT_ORDERING; + private UShort nodeNamespaceIndex = UShort.valueOf(1); + private boolean findAliasVerboseEnabled = false; + private boolean configurationEnabled = false; + + private Builder() {} + + /** + * Set the store used to persist category {@code LastChange} versions. + * + *

Default: {@link InMemoryAliasVersionStore} (test/demo use only). + * + * @param versionStore the {@link AliasVersionStore} to use. + * @return this {@link Builder}. + */ + public Builder versionStore(AliasVersionStore versionStore) { + this.versionStore = Objects.requireNonNull(versionStore, "versionStore must be non-null"); + return this; + } + + /** + * Set the policy deciding whether sessions may search or mutate categories over the network. + * + *

Default: {@link AliasAuthorizationPolicy#ALLOW_FIND_DENY_MUTATE}. + * + * @param authorizationPolicy the {@link AliasAuthorizationPolicy} to use. + * @return this {@link Builder}. + */ + public Builder authorizationPolicy(AliasAuthorizationPolicy authorizationPolicy) { + this.authorizationPolicy = + Objects.requireNonNull(authorizationPolicy, "authorizationPolicy must be non-null"); + return this; + } + + /** + * Set the limits applied to alias lookup and mutation calls. + * + *

Default: {@link AliasLimits#defaults()}. + * + * @param limits the {@link AliasLimits} to use. + * @return this {@link Builder}. + */ + public Builder limits(AliasLimits limits) { + this.limits = Objects.requireNonNull(limits, "limits must be non-null"); + return this; + } + + /** + * Set the ordering applied to the targets within each result entry. + * + *

Default: local targets before remote, then by the target NodeId's parseable string form. + * + * @param targetOrdering the target {@link Comparator} to use. + * @return this {@link Builder}. + */ + public Builder targetOrdering(Comparator targetOrdering) { + this.targetOrdering = + Objects.requireNonNull(targetOrdering, "targetOrdering must be non-null"); + return this; + } + + /** + * Set the namespace index used to allocate NodeIds for the Nodes the manager itself creates: + * materialized Method Nodes and default-factory alias Nodes. + * + *

Default: namespace index 1. + * + * @param nodeNamespaceIndex the namespace index to use. + * @return this {@link Builder}. + */ + public Builder nodeNamespaceIndex(UShort nodeNamespaceIndex) { + this.nodeNamespaceIndex = + Objects.requireNonNull(nodeNamespaceIndex, "nodeNamespaceIndex must be non-null"); + return this; + } + + /** + * Set whether {@code FindAliasVerbose} is enabled on the standard categories. + * + *

Default: {@code false}. + * + * @param findAliasVerboseEnabled {@code true} to enable {@code FindAliasVerbose}. + * @return this {@link Builder}. + */ + public Builder findAliasVerboseEnabled(boolean findAliasVerboseEnabled) { + this.findAliasVerboseEnabled = findAliasVerboseEnabled; + return this; + } + + /** + * Set whether the {@code AddAliasesToCategory} and {@code DeleteAliasesFromCategory} Methods + * are materialized and bound on the standard categories. + * + *

Enabling the Methods does not by itself allow network mutation: the default {@link + * AliasAuthorizationPolicy} denies every session, so an explicit policy grant is also required. + * + *

Default: {@code false}. + * + * @param configurationEnabled {@code true} to enable {@code AddAliasesToCategory} and {@code + * DeleteAliasesFromCategory}. + * @return this {@link Builder}. + */ + public Builder configurationEnabled(boolean configurationEnabled) { + this.configurationEnabled = configurationEnabled; + return this; + } + + /** + * Build an immutable {@link AliasManagerConfig} from the current settings. + * + * @return a new, immutable {@link AliasManagerConfig}. + */ + public AliasManagerConfig build() { + return new ConfigImpl( + versionStore, + authorizationPolicy, + limits, + targetOrdering, + nodeNamespaceIndex, + findAliasVerboseEnabled, + configurationEnabled); + } + + /** The immutable {@link AliasManagerConfig} produced by {@link Builder#build()}. */ + private record ConfigImpl( + AliasVersionStore versionStore, + AliasAuthorizationPolicy authorizationPolicy, + AliasLimits limits, + Comparator targetOrdering, + UShort nodeNamespaceIndex, + boolean findAliasVerboseEnabled, + boolean configurationEnabled) + implements AliasManagerConfig { + + @Override + public AliasVersionStore getVersionStore() { + return versionStore; + } + + @Override + public AliasAuthorizationPolicy getAuthorizationPolicy() { + return authorizationPolicy; + } + + @Override + public AliasLimits getLimits() { + return limits; + } + + @Override + public Comparator getTargetOrdering() { + return targetOrdering; + } + + @Override + public UShort getNodeNamespaceIndex() { + return nodeNamespaceIndex; + } + + @Override + public boolean isFindAliasVerboseEnabled() { + return findAliasVerboseEnabled; + } + + @Override + public boolean isConfigurationEnabled() { + return configurationEnabled; + } + } + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasSearchEngine.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasSearchEngine.java new file mode 100644 index 0000000000..fa583ad536 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasSearchEngine.java @@ -0,0 +1,496 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import org.eclipse.milo.opcua.sdk.core.Reference; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.sdk.server.util.LikeMatcher; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType; +import org.jspecify.annotations.Nullable; + +/** + * Evaluates {@code FindAlias} and {@code FindAliasVerbose} queries against the live AddressSpace. + * + *

A search starts at a category Node and walks forward {@code Organizes} References, recursing + * into every {@code AliasNameCategoryType} instance it reaches and collecting every {@code + * AliasNameType} instance. Each alias Object appears at most once in the result, no matter how many + * subtree paths reach it. An alias matches when the text of its BrowseName — the name alone, + * ignoring the namespace index, per Part 17 §6.2 — matches the Part 4 {@code Like} pattern, and at + * least one of its forward {@code AliasFor}-or-subtype References passes the ReferenceType filter; + * an alias with no passing target References is omitted entirely, so a filter that nothing + * satisfies produces an empty result (§6.3.2/§6.3.3: if no Nodes "match the search string or have + * the appropriate ReferenceType, the list shall be empty"). + * + *

Results are deterministically ordered: entries by alias name text, then by alias NodeId; + * targets within an entry by the configured target ordering (default: local-before-remote, then by + * target NodeId). Verbose results report, for each alias, the containing category closest to the + * search root (ties broken by category BrowseName, then category NodeId). + * + *

The engine reads whatever the AddressSpace currently contains, so aliases loaded from a + * NodeSet file or created by other components are found without registration. Searches take no + * lock: a search overlapping a concurrent mutation may observe a partially applied change — the + * same weak consistency Browse has over the live AddressSpace. + * + *

Instances are stateless between calls and safe for concurrent use. + */ +public final class AliasSearchEngine { + + /** + * Verbose category selection: smallest depth from the search root, then category BrowseName, then + * category NodeId. + */ + private static final Comparator CATEGORY_ORDER = + Comparator.comparingInt(CategoryRef::depth) + .thenComparing(c -> c.browseName().toParseableString()) + .thenComparing(c -> c.nodeId().toParseableString()); + + /** Result entries: alias name text first, then alias NodeId for identically named aliases. */ + private static final Comparator ENTRY_ORDER = + Comparator.comparing((MatchedAlias m) -> m.nameText).thenComparing(m -> m.parseableNodeId); + + private final LikeMatcher likeMatcher = new LikeMatcher(); + + private final OpcUaServer server; + private final AliasTypes aliasTypes; + private final AliasLimits limits; + private final Comparator targetOrdering; + + /** + * Create an engine that searches {@code server}'s AddressSpace. + * + * @param server the server whose AddressSpace is searched. + * @param limits the limits enforced on every call. + * @param targetOrdering the ordering applied to the targets within each result entry. + */ + public AliasSearchEngine( + OpcUaServer server, AliasLimits limits, Comparator targetOrdering) { + + this.server = server; + this.aliasTypes = new AliasTypes(server); + this.limits = limits; + this.targetOrdering = targetOrdering; + } + + /** + * Find aliases under {@code categoryId} whose name matches {@code pattern}. + * + * @param categoryId the NodeId of the {@code AliasNameCategoryType} instance to search from. + * @param pattern a Part 4 {@code Like} pattern matched against alias name text. + * @param referenceTypeFilter restricts targets to References of this type or a subtype; a null + * (or null-valued) NodeId means no restriction beyond {@code AliasFor} and its subtypes. + * @return the matching entries, each with at least one target, ordered by alias name text then + * alias NodeId. + * @throws UaException with {@code Bad_InvalidArgument} if the pattern is too long or malformed, + * or if the filter is not a known ReferenceType; {@code Bad_NodeIdUnknown} if {@code + * categoryId} is not an {@code AliasNameCategoryType} instance; {@code Bad_ResponseTooLarge} + * if more entries match than the configured maximum. + */ + public List findAlias( + NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException { + + return findAlias(categoryId, pattern, referenceTypeFilter, aliasNodeId -> true); + } + + /** + * Find aliases under {@code categoryId} whose name matches {@code pattern}, applying a per-result + * authorization filter. + * + *

Entries whose alias NodeId the filter rejects are omitted from the result. Filtering happens + * after matching, so rejected entries still count toward the configured result maximum. + * + * @param categoryId the NodeId of the {@code AliasNameCategoryType} instance to search from. + * @param pattern a Part 4 {@code Like} pattern matched against alias name text. + * @param referenceTypeFilter restricts targets to References of this type or a subtype; a null + * (or null-valued) NodeId means no restriction beyond {@code AliasFor} and its subtypes. + * @param resultFilter decides, by alias NodeId, whether a matched alias may appear in the result. + * @return the matching, filter-approved entries, ordered by alias name text then alias NodeId. + * @throws UaException under the same conditions as {@link #findAlias(NodeId, String, NodeId)}. + */ + List findAlias( + NodeId categoryId, + String pattern, + @Nullable NodeId referenceTypeFilter, + Predicate resultFilter) + throws UaException { + + List matches = search(categoryId, pattern, referenceTypeFilter); + + List results = new ArrayList<>(matches.size()); + for (MatchedAlias match : matches) { + if (!resultFilter.test(match.nodeId)) { + continue; + } + results.add( + new AliasNameDataType( + match.browseName, + match.targets.stream().map(AliasTarget::nodeId).toArray(ExpandedNodeId[]::new))); + } + return results; + } + + /** + * Find aliases under {@code categoryId} whose name matches {@code pattern}, with containing + * category and target server details. + * + *

An entry's {@code ServerUris} field is null when every target is local (permitted by Part 17 + * §7.3, which allows a null URI for any local Node). When an alias carries remote target + * References — possible for aliases created outside the manager, e.g. loaded from a NodeSet file + * — {@code ServerUris} is an array parallel to the referenced Nodes: the remote targets' Server + * URIs (or, for a server index the ServerTable cannot resolve, the raw index as text), null for + * local targets. + * + * @param categoryId the NodeId of the {@code AliasNameCategoryType} instance to search from. + * @param pattern a Part 4 {@code Like} pattern matched against alias name text. + * @param referenceTypeFilter restricts targets to References of this type or a subtype; a null + * (or null-valued) NodeId means no restriction beyond {@code AliasFor} and its subtypes. + * @return the matching entries, each with at least one target, ordered by alias name text then + * alias NodeId. + * @throws UaException with {@code Bad_InvalidArgument} if the pattern is too long or malformed, + * or if the filter is not a known ReferenceType; {@code Bad_NodeIdUnknown} if {@code + * categoryId} is not an {@code AliasNameCategoryType} instance; {@code Bad_ResponseTooLarge} + * if more entries match than the configured maximum. + */ + public List findAliasVerbose( + NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException { + + return findAliasVerbose(categoryId, pattern, referenceTypeFilter, aliasNodeId -> true); + } + + /** + * Find aliases under {@code categoryId} whose name matches {@code pattern}, with containing + * category and target server details, applying a per-result authorization filter. + * + *

Entries whose alias NodeId the filter rejects are omitted from the result. Filtering happens + * after matching, so rejected entries still count toward the configured result maximum. + * + * @param categoryId the NodeId of the {@code AliasNameCategoryType} instance to search from. + * @param pattern a Part 4 {@code Like} pattern matched against alias name text. + * @param referenceTypeFilter restricts targets to References of this type or a subtype; a null + * (or null-valued) NodeId means no restriction beyond {@code AliasFor} and its subtypes. + * @param resultFilter decides, by alias NodeId, whether a matched alias may appear in the result. + * @return the matching, filter-approved entries, ordered by alias name text then alias NodeId. + * @throws UaException under the same conditions as {@link #findAliasVerbose(NodeId, String, + * NodeId)}. + */ + List findAliasVerbose( + NodeId categoryId, + String pattern, + @Nullable NodeId referenceTypeFilter, + Predicate resultFilter) + throws UaException { + + List matches = search(categoryId, pattern, referenceTypeFilter); + + List results = new ArrayList<>(matches.size()); + for (MatchedAlias match : matches) { + if (!resultFilter.test(match.nodeId)) { + continue; + } + results.add( + new AliasNameVerboseDataType( + match.browseName, + match.targets.stream().map(AliasTarget::nodeId).toArray(ExpandedNodeId[]::new), + serverUrisOf(match.targets), + match.category.nodeId())); + } + return results; + } + + private List search( + NodeId categoryId, String pattern, @Nullable NodeId referenceTypeFilter) throws UaException { + + if (pattern.length() > limits.maxPatternLength()) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, + "pattern length %d exceeds maximum %d" + .formatted(pattern.length(), limits.maxPatternLength())); + } + + // Compile the pattern up front: a malformed pattern fails the call before any traversal + // happens (§6.3.2 maps invalid search strings to Bad_InvalidArgument), and the per-alias + // match then runs against the compiled form, free of the matcher's shared pattern cache. + LikeMatcher.CompiledPattern compiledPattern; + try { + compiledPattern = likeMatcher.compile(pattern); + } catch (IllegalArgumentException e) { + throw new UaException(StatusCodes.Bad_InvalidArgument, "malformed pattern: " + pattern, e); + } + + // A null NodeId on the wire arrives as NodeId.NULL_VALUE; both spellings mean "no filter". + NodeId filter = + referenceTypeFilter == null || referenceTypeFilter.isNull() ? null : referenceTypeFilter; + if (filter != null && !server.getReferenceTypeTree().containsType(filter)) { + throw new UaException( + StatusCodes.Bad_InvalidArgument, "unknown ReferenceType: " + filter.toParseableString()); + } + + UaNode categoryNode = + server + .getAddressSpaceManager() + .getManagedNode(categoryId) + .orElseThrow( + () -> + new UaException( + StatusCodes.Bad_NodeIdUnknown, + "category not found: " + categoryId.toParseableString())); + + if (!aliasTypes.isAliasNameCategoryInstance(categoryId)) { + throw new UaException( + StatusCodes.Bad_NodeIdUnknown, + "not an AliasNameCategoryType instance: " + categoryId.toParseableString()); + } + + return traverse( + new CategoryRef(categoryId, categoryNode.getBrowseName(), 0), compiledPattern, filter); + } + + /** + * Breadth-first walk of the category subtree rooted at {@code root}, collecting matched aliases. + * + *

Breadth-first order visits categories in non-decreasing depth, which the verbose + * category-selection rule (smallest depth first) relies on only loosely: candidates are compared + * explicitly, so ties at equal depth are still broken deterministically. + */ + private List traverse( + CategoryRef root, LikeMatcher.CompiledPattern pattern, @Nullable NodeId filter) + throws UaException { + + // LinkedHashMap only for deterministic iteration while debugging; results are re-sorted below. + Map matched = new LinkedHashMap<>(); + Set rejected = new HashSet<>(); + Set visitedCategories = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + + visitedCategories.add(root.nodeId()); + queue.addLast(root); + + while (!queue.isEmpty()) { + CategoryRef category = queue.removeFirst(); + + List organizes = + server + .getAddressSpaceManager() + .getManagedReferences(category.nodeId(), Reference.ORGANIZES_PREDICATE); + + for (Reference reference : organizes) { + Optional targetId = + reference.getTargetNodeId().toNodeId(server.getNamespaceTable()); + if (targetId.isEmpty()) { + continue; + } + NodeId organizedId = targetId.get(); + + NodeId typeDefinitionId = aliasTypes.getTypeDefinitionId(organizedId); + if (typeDefinitionId == null) { + continue; + } + + if (aliasTypes.isAliasNameCategoryType(typeDefinitionId)) { + if (visitedCategories.add(organizedId)) { + server + .getAddressSpaceManager() + .getManagedNode(organizedId) + .ifPresent( + subcategoryNode -> + queue.addLast( + new CategoryRef( + organizedId, + subcategoryNode.getBrowseName(), + category.depth() + 1))); + } + } else if (aliasTypes.isAliasNameType(typeDefinitionId)) { + processAlias(organizedId, category, pattern, filter, matched, rejected); + } + } + } + + List results = new ArrayList<>(matched.values()); + results.sort(ENTRY_ORDER); + return results; + } + + private void processAlias( + NodeId aliasNodeId, + CategoryRef category, + LikeMatcher.CompiledPattern pattern, + @Nullable NodeId filter, + Map matched, + Set rejected) + throws UaException { + + MatchedAlias existing = matched.get(aliasNodeId); + if (existing != null) { + // Already matched via another subtree path: only the verbose category selection can change. + if (CATEGORY_ORDER.compare(category, existing.category) < 0) { + existing.category = category; + } + return; + } + if (rejected.contains(aliasNodeId)) { + // Whether an alias matches is a property of the alias Node alone, not of the path that + // reached it, so a rejection holds for every other path too. + return; + } + + UaNode aliasNode = server.getAddressSpaceManager().getManagedNode(aliasNodeId).orElse(null); + if (aliasNode == null) { + rejected.add(aliasNodeId); + return; + } + + QualifiedName browseName = aliasNode.getBrowseName(); + String nameText = browseName.name(); + if (nameText == null || !pattern.matches(nameText)) { + rejected.add(aliasNodeId); + return; + } + + List targets = collectTargets(aliasNodeId, filter); + if (targets.isEmpty()) { + // No target Reference passed the filter; an entry without targets is not returned + // (§6.3.2/§6.3.3: Nodes that don't "have the appropriate ReferenceType" contribute nothing). + rejected.add(aliasNodeId); + return; + } + + if (matched.size() >= limits.maxResults()) { + throw new UaException( + StatusCodes.Bad_ResponseTooLarge, + "more than %d aliases match".formatted(limits.maxResults())); + } + + matched.put( + aliasNodeId, new MatchedAlias(aliasNodeId, browseName, nameText, targets, category)); + } + + /** + * Collect the targets of {@code aliasNodeId}: forward References whose type is {@code AliasFor} + * or a subtype and, when a filter is present, also the filter type or a subtype of it. + * + *

Targets are sorted with the configured ordering; when several References of different types + * reach the same target Node, the earliest-ordered occurrence decides its position. + */ + private List collectTargets(NodeId aliasNodeId, @Nullable NodeId filter) { + List references = + server + .getAddressSpaceManager() + .getManagedReferences( + aliasNodeId, + reference -> + reference.isForward() + && aliasTypes.isAliasForOrSubtype(reference.getReferenceTypeId()) + && (filter == null + || matchesReferenceType(reference.getReferenceTypeId(), filter))); + + List ordered = + references.stream().map(this::toAliasTarget).sorted(targetOrdering).toList(); + + var seen = new HashSet(); + var targets = new ArrayList(ordered.size()); + for (AliasTarget target : ordered) { + if (seen.add(target.nodeId())) { + targets.add(target); + } + } + return targets; + } + + /** + * The {@code ServerUris} output entries for {@code targets}: null when every target is local + * (permitted by Part 17 §7.3), otherwise an array parallel to the referenced Nodes whose entries + * are the remote targets' Server URIs and null for local targets. + */ + private static @Nullable String @Nullable [] serverUrisOf(List targets) { + if (targets.stream().allMatch(AliasTarget::isLocal)) { + return null; + } + + var serverUris = new @Nullable String[targets.size()]; + for (int i = 0; i < serverUris.length; i++) { + serverUris[i] = targets.get(i).serverUri(); + } + return serverUris; + } + + /** + * Adapt a target Reference to an {@link AliasTarget} so the configured {@code + * Comparator} can order it. + * + *

The server URI of a remote target is resolved through the ServerTable; if the target's + * server index has no ServerTable entry, the raw index is carried in the URI position so the + * target still classifies as remote for ordering purposes. + */ + private AliasTarget toAliasTarget(Reference reference) { + ExpandedNodeId targetId = reference.getTargetNodeId(); + + String serverUri = null; + if (!targetId.isLocal()) { + serverUri = targetId.getServerUri(server.getServerTable()); + if (serverUri == null) { + serverUri = String.valueOf(targetId.getServerIndex()); + } + } + + return new AliasTarget(targetId, serverUri, reference.getReferenceTypeId()); + } + + /** "Any ReferenceType includes all subtypes" (§6.3.3), so a filter admits itself and subtypes. */ + private boolean matchesReferenceType(NodeId referenceTypeId, NodeId filter) { + return referenceTypeId.equals(filter) + || server.getReferenceTypeTree().isSubtypeOf(referenceTypeId, filter); + } + + /** A category on the traversal frontier, with its depth from the search root (root = 0). */ + private record CategoryRef(NodeId nodeId, QualifiedName browseName, int depth) {} + + /** A matched alias accumulated during traversal; {@code category} is refined as paths arrive. */ + private static final class MatchedAlias { + + final NodeId nodeId; + final QualifiedName browseName; + final String nameText; + final String parseableNodeId; + final List targets; + + CategoryRef category; + + MatchedAlias( + NodeId nodeId, + QualifiedName browseName, + String nameText, + List targets, + CategoryRef category) { + this.nodeId = nodeId; + this.browseName = browseName; + this.nameText = nameText; + this.parseableNodeId = nodeId.toParseableString(); + this.targets = targets; + this.category = category; + } + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTarget.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTarget.java new file mode 100644 index 0000000000..d875ec50f9 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTarget.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.Comparator; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.jspecify.annotations.Nullable; + +/** + * A single target of an alias: the Node an alias name resolves to, and the ReferenceType that + * associates the alias with it. + * + *

An alias may have any number of targets; Clients treat the order they are returned in as an + * order of preference. Target ordering is controlled by {@link + * AliasManagerConfig#getTargetOrdering()}. + * + * @param nodeId the NodeId of the target Node. May identify a Node on a remote Server, in which + * case {@code serverUri} identifies that Server. + * @param serverUri the URI of the Server the target Node resides on, or {@code null} if the target + * is on the local Server. + * @param referenceTypeId the NodeId of the ReferenceType associating the alias with the target. + * Must be {@code AliasFor} or a subtype; this is validated where the target is applied, not at + * construction, because subtype checks require the Server's ReferenceType hierarchy. + */ +public record AliasTarget( + ExpandedNodeId nodeId, @Nullable String serverUri, NodeId referenceTypeId) { + + /** + * The default target ordering: local targets before remote ones, then by the target NodeId's + * parseable string form, so results are deterministic. + * + *

This is the single definition of the default, applied by {@link AliasManagerConfig.Builder} + * when no ordering is configured. + */ + public static final Comparator DEFAULT_ORDERING = + Comparator.comparing((AliasTarget target) -> !target.isLocal()) + .thenComparing(target -> target.nodeId().toParseableString()); + + /** + * Whether this target resides on the local Server. + * + * @return {@code true} if this target resides on the local Server, i.e. {@link #serverUri()} is + * {@code null}. + */ + public boolean isLocal() { + return serverUri == null; + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTypes.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTypes.java new file mode 100644 index 0000000000..830627e6ad --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasTypes.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.eclipse.milo.opcua.sdk.core.Reference; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.jspecify.annotations.Nullable; + +/** + * The Part 17 type tests shared by the alias components: type-definition resolution and + * equals-or-subtype checks against {@code AliasNameCategoryType}, {@code AliasNameType}, and {@code + * AliasFor}. + * + *

A Node's type definition is the target of its first {@code HasTypeDefinition} Reference; a + * well-formed Node has exactly one. {@code TypeTree.isSubtypeOf} is strict, so every check handles + * the equals case explicitly. + * + *

Stateless: every call reads the server's live AddressSpace and type trees. + */ +final class AliasTypes { + + private final OpcUaServer server; + + AliasTypes(OpcUaServer server) { + this.server = server; + } + + /** The type definition of the Node identified by {@code nodeId}, or null if it has none. */ + @Nullable NodeId getTypeDefinitionId(NodeId nodeId) { + return server + .getAddressSpaceManager() + .getManagedReferences(nodeId, Reference.HAS_TYPE_DEFINITION_PREDICATE) + .stream() + .findFirst() + .flatMap(reference -> reference.getTargetNodeId().toNodeId(server.getNamespaceTable())) + .orElse(null); + } + + /** Whether {@code typeDefinitionId} is {@code AliasNameCategoryType} or a subtype. */ + boolean isAliasNameCategoryType(NodeId typeDefinitionId) { + return NodeIds.AliasNameCategoryType.equals(typeDefinitionId) + || server.getObjectTypeTree().isSubtypeOf(typeDefinitionId, NodeIds.AliasNameCategoryType); + } + + /** Whether {@code typeDefinitionId} is {@code AliasNameType} or a subtype. */ + boolean isAliasNameType(NodeId typeDefinitionId) { + return NodeIds.AliasNameType.equals(typeDefinitionId) + || server.getObjectTypeTree().isSubtypeOf(typeDefinitionId, NodeIds.AliasNameType); + } + + /** Whether the Node identified by {@code nodeId} is an {@code AliasNameCategoryType} instance. */ + boolean isAliasNameCategoryInstance(NodeId nodeId) { + NodeId typeDefinitionId = getTypeDefinitionId(nodeId); + + return typeDefinitionId != null && isAliasNameCategoryType(typeDefinitionId); + } + + /** Whether the Node identified by {@code nodeId} is an {@code AliasNameType} instance. */ + boolean isAliasNameInstance(NodeId nodeId) { + NodeId typeDefinitionId = getTypeDefinitionId(nodeId); + + return typeDefinitionId != null && isAliasNameType(typeDefinitionId); + } + + /** Whether {@code referenceTypeId} is {@code AliasFor} or a subtype. */ + boolean isAliasForOrSubtype(NodeId referenceTypeId) { + return NodeIds.AliasFor.equals(referenceTypeId) + || server.getReferenceTypeTree().isSubtypeOf(referenceTypeId, NodeIds.AliasFor); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManager.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManager.java new file mode 100644 index 0000000000..7934bada30 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManager.java @@ -0,0 +1,360 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.eclipse.milo.opcua.sdk.core.Reference; +import org.eclipse.milo.opcua.sdk.core.nodes.VariableNode; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryTypeNode; +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Owns the {@code LastChange} ({@code VersionTime}) state of alias categories: computes new values, + * propagates bumps through ancestor categories, persists values through the {@link + * AliasVersionStore}, and writes the {@code LastChange} Property Nodes. + * + *

Version maintenance is split into two phases so persistence happens before the + * AddressSpace mutation a bump describes: {@link #prepare} computes the next value for each + * affected category (and its ancestors) and saves it through the store, throwing — and thereby + * aborting the mutation before it is applied — if a save fails; {@link #publishPending} then writes + * the prepared values to the {@code LastChange} Property Nodes, called from a {@code finally} so + * values whose mutation partially applied are still published. Because no value becomes observable + * before it is persisted, a restart can never re-produce an observed {@code LastChange} value for + * different content — the failure mode that would leave Client caches undetectably stale. + * + *

Store entries are keyed by namespace-URI-qualified {@link ExpandedNodeId}s (see {@link + * AliasVersionStore}); this class converts to and from runtime NodeIds at the store boundary. + * + *

Internal to the aliases package. Not thread-safe on its own: the owning manager serializes + * every call under its write lock, so version computation, persistence, and Property publication + * never interleave. + * + *

{@code VersionTime} values are seconds since 2000-01-01T00:00:00Z. Each bump computes {@code + * next = max(secondsSince2000(now), previous + 1)}, so values are strictly monotonic per category + * even under clock rollback. Wraparound of the 32-bit range (year 2136) is not handled; Part 4 + * §7.43 defines no wraparound behavior. + */ +class AliasVersionManager { + + /** The {@code VersionTime} epoch, 2000-01-01T00:00:00Z, as a Unix epoch second. */ + private static final long VERSION_TIME_EPOCH_SECOND = 946684800L; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Map versions = new HashMap<>(); + + /** + * Values persisted by {@link #prepare} but not yet written to their {@code LastChange} Property + * Nodes, in preparation order. Bounded by the categories one mutation call touches: every + * prepare/publish pair runs under the owning manager's lock. + */ + private final Map pending = new LinkedHashMap<>(); + + private final OpcUaServer server; + private final AliasTypes aliasTypes; + private final AliasVersionStore store; + + AliasVersionManager(OpcUaServer server, AliasVersionStore store) { + this.server = server; + this.aliasTypes = new AliasTypes(server); + this.store = store; + } + + /** + * Load persisted category versions from the store and seed the in-memory version state. + * + *

Called once at manager startup, before any bump. Nothing is written to the {@code + * LastChange} Property Nodes yet — the caller invokes {@link #publishLoaded} once the rest of + * startup has succeeded, so a startup that fails after loading leaves no trace in the + * AddressSpace. A stored entry whose namespace URI is not in the Server's namespace table cannot + * belong to any live category; it is skipped with a warning and remains untouched in the store. + * + * @return an immutable copy of the loaded versions, keyed by category NodeId. + * @throws UaException if the store cannot be read; the caller must fail startup, because + * continuing with silently reset versions would violate the persistence contract. + */ + Map loadPersisted() throws UaException { + var loaded = new HashMap(); + + store + .load() + .forEach( + (storeKey, value) -> { + Optional categoryId = storeKey.toNodeId(server.getNamespaceTable()); + + if (categoryId.isPresent()) { + loaded.put(categoryId.get(), value); + } else { + logger.warn( + "Ignoring persisted LastChange with unregistered namespace: key={}, value={}", + storeKey.toParseableString(), + value); + } + }); + + versions.putAll(loaded); + + return Map.copyOf(loaded); + } + + /** + * Write every version loaded by {@link #loadPersisted} to its category's {@code LastChange} + * Property Node, where that Node exists. + * + *

Called as the final step of a successful startup — after every step that can fail — so a + * failed startup never leaves loaded values published. + */ + void publishLoaded() { + versions.forEach(this::writeLastChangeProperty); + } + + /** + * Compute and persist the next version of each category in {@code categoryIds} and of every + * ancestor category reachable from them, without publishing anything yet. + * + *

Called before the AddressSpace mutation the bump describes. A category already + * prepared since the last {@link #publishPending} is skipped, so a multi-entry mutation call + * persists (and ultimately publishes) one new value per category, no matter how many entries + * touch it. + * + *

Ancestors are discovered by walking inverse {@code Organizes} References while the parent is + * an {@code AliasNameCategoryType} instance, stopping at (and including) the standard root {@code + * Aliases} Object when reached. Categories without a {@code LastChange} Property (it is Optional + * per category; the standard {@code TagVariables} and {@code Topics} Objects have none) still get + * their version persisted and their in-memory value advanced — only the eventual Property write + * is skipped. + * + * @param categoryIds the NodeIds of the categories directly affected by the impending mutation. + * @throws UaException with {@code Bad_InternalError} if a store save fails; the caller must abort + * the mutation. Values saved before the failure stay pending and are still published, which + * at worst bumps a category without a content change — the safe direction. + */ + void prepare(Collection categoryIds) throws UaException { + Set affected = new LinkedHashSet<>(); + + for (NodeId categoryId : categoryIds) { + if (affected.add(categoryId)) { + collectAncestorCategories(categoryId, affected); + } + } + + for (NodeId categoryId : affected) { + if (pending.containsKey(categoryId)) { + continue; + } + + UInteger previous = versions.get(categoryId); + long previousValue = previous != null ? previous.longValue() : 0L; + + long next = Math.max(secondsSince2000(), previousValue + 1); + UInteger value = UInteger.valueOf(next); + + try { + store.save(categoryId.expanded(server.getNamespaceTable()), value); + } catch (Exception e) { + throw new UaException( + StatusCodes.Bad_InternalError, + "failed to persist LastChange for category " + categoryId.toParseableString(), + e); + } + + versions.put(categoryId, value); + pending.put(categoryId, value); + } + } + + /** + * Write every value {@link #prepare} persisted since the last publish to its category's {@code + * LastChange} Property Node, then clear the pending set. + * + *

Called from a {@code finally} after the mutation, so prepared values are published even when + * the mutation partially applied. Publishing when nothing is pending is a no-op. A Property write + * failure is logged and the remaining values still publish: the value is already persisted and in + * memory, and a secondary failure must not mask the mutation's own outcome. + */ + void publishPending() { + pending.forEach( + (categoryId, value) -> { + try { + writeLastChangeProperty(categoryId, value); + } catch (RuntimeException e) { + logger.warn( + "Failed to write LastChange Property: category={}, value={}", + categoryId.toParseableString(), + value, + e); + } + }); + + pending.clear(); + } + + /** + * Bump the version of a single category and its ancestor categories: {@link #prepare} and {@link + * #publishPending} as one step, for callers with no surrounding mutation to order against. + * + * @param categoryId the NodeId of the category to bump. + * @throws UaException with {@code Bad_InternalError} if a store save fails. + */ + void touch(NodeId categoryId) throws UaException { + try { + prepare(List.of(categoryId)); + } finally { + publishPending(); + } + } + + /** + * The current version of a category, if one has been loaded or computed. + * + * @param categoryId the NodeId of the category. + * @return the category's current version, or empty if it has never been bumped or loaded. + */ + Optional get(NodeId categoryId) { + return Optional.ofNullable(versions.get(categoryId)); + } + + /** + * The ancestor categories of {@code categoryId}: every {@code AliasNameCategoryType} instance + * reachable by walking inverse {@code Organizes} References, up to and including the standard + * root {@code Aliases} Object. {@code categoryId} itself is not included. + * + *

Subject to the visibility limitation documented on {@link #collectAncestorCategories}: + * parent linkage stored only as a one-directional forward Reference on the parent's side is not + * discovered. + * + * @param categoryId the NodeId of the category whose ancestors are collected. + * @return the ancestor category NodeIds, in discovery order. + */ + List getAncestorCategories(NodeId categoryId) { + var ancestors = new LinkedHashSet(); + collectAncestorCategories(categoryId, ancestors); + return List.copyOf(ancestors); + } + + /** + * Drop the version entry of a category that no longer exists, in memory and — best-effort — in + * the store. + * + *

The store delete is cleanup, not correctness: a failure is logged and the removal proceeds, + * and stores whose {@link AliasVersionStore#delete} is the default no-op simply keep the entry. A + * leftover entry is inert unless a category with the same NodeId is created again, in which case + * the monotonic version sequence resumes from the persisted value — which is the safe direction. + * + * @param categoryId the NodeId of the removed category. + */ + void remove(NodeId categoryId) { + versions.remove(categoryId); + pending.remove(categoryId); + + try { + store.delete(categoryId.expanded(server.getNamespaceTable())); + } catch (Exception e) { + logger.warn( + "Failed to delete persisted LastChange for category {}", + categoryId.toParseableString(), + e); + } + } + + private long secondsSince2000() { + return Math.max(0L, Instant.now().getEpochSecond() - VERSION_TIME_EPOCH_SECOND); + } + + /** + * Walk inverse {@code Organizes} References from {@code categoryId}, adding every ancestor that + * is an {@code AliasNameCategoryType} instance to {@code affected}, stopping at (and including) + * the standard root {@code Aliases} Object. + * + *

References are read through the AddressSpaceManager, which aggregates every registered + * NodeManager, so linkage stored by either side's NodeManager is visible as long as both + * directions were recorded. A parent linkage recorded only as a one-directional forward Reference + * on the parent category's side — possible when References are added out-of-band, e.g. by a + * NodeSet loader that does not write inverses — is invisible from the child and is missed, so + * version bumps do not propagate across such an edge. + */ + private void collectAncestorCategories(NodeId categoryId, Set affected) { + Deque queue = new ArrayDeque<>(); + queue.push(categoryId); + + while (!queue.isEmpty()) { + NodeId current = queue.pop(); + + List references = + server + .getAddressSpaceManager() + .getManagedReferences(current, Reference.ORGANIZED_BY_PREDICATE); + + for (Reference reference : references) { + Optional parentId = + reference.getTargetNodeId().toNodeId(server.getNamespaceTable()); + + parentId.ifPresent( + id -> { + if (NodeIds.Aliases.equals(id)) { + // The standard root: include it, but never walk beyond it. + affected.add(id); + } else if (aliasTypes.isAliasNameCategoryInstance(id) && affected.add(id)) { + queue.push(id); + } + }); + } + } + } + + /** + * Write {@code value} to the category's {@code LastChange} Property Node, if that Node exists. + * + *

The Property is never created here: it is Optional per category, and whether a category has + * one is decided when the category is materialized (or by the NodeSet that defined it). + */ + private void writeLastChangeProperty(NodeId categoryId, UInteger value) { + Optional node = server.getAddressSpaceManager().getManagedNode(categoryId); + + node.ifPresent( + categoryNode -> { + if (categoryNode instanceof AliasNameCategoryTypeNode typedNode) { + // setLastChange would create the Property if absent; only write where it exists. + if (typedNode.getLastChangeNode() != null) { + typedNode.setLastChange(value); + } + } else { + Optional propertyNode = + categoryNode.getPropertyNode(AliasNameCategoryType.LAST_CHANGE); + + propertyNode.ifPresent( + property -> property.setValue(new DataValue(new Variant(value)))); + } + }); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionStore.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionStore.java new file mode 100644 index 0000000000..37f68ecd1e --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionStore.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.Map; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; + +/** + * SPI for persisting alias category {@code LastChange} versions across Server restarts. + * + *

Part 17 §6.3.1 requires the {@code LastChange} value of the root {@code Aliases} Object to be + * persisted: Clients cache alias lookups keyed by it, and a value that silently resets after a + * restart would leave those caches undetectably stale. The store persists nothing else — alias and + * category definitions are the application's concern. + * + *

Entries are keyed by a namespace-URI-qualified {@link ExpandedNodeId} of the category's + * NodeId, with the category's current {@code VersionTime} (seconds since 2000-01-01T00:00:00Z) as + * the value. Keys carry the namespace URI rather than a namespace index because indices are a + * runtime artifact of namespace-table registration order: a URI-qualified key stays valid across + * restarts even when the table assigns the namespace a different index. A store only ever holds + * entries written by {@link #save}, so the key set mirrors the categories whose versions have been + * bumped at least once. + * + *

Implementations do not need to be thread-safe; the manager serializes all store access. + */ +public interface AliasVersionStore { + + /** + * Load all persisted category versions. + * + *

Called once, at manager startup. A thrown exception fails startup deliberately: continuing + * with silently reset versions would violate the Part 17 §6.3.1 persistence contract + * undetectably. A store with nothing persisted yet returns an empty map. + * + * @return the persisted versions, keyed by namespace-URI-qualified category ExpandedNodeId; empty + * if nothing has been persisted. + * @throws UaException if the persisted state cannot be read. + */ + Map load() throws UaException; + + /** + * Persist the version of a single category. + * + *

Called whenever a category's {@code LastChange} value is about to be bumped, before + * the AddressSpace mutation the bump describes is applied and before the new value becomes + * observable. A thrown exception aborts the mutating operation ({@code Bad_InternalError}), so + * every value a Client can ever observe has been persisted first — after a restart, {@code + * LastChange} can then never repeat an observed value for different content, which would leave + * Client caches undetectably stale. + * + * @param categoryId the namespace-URI-qualified ExpandedNodeId of the category the version + * belongs to. + * @param value the category's new {@code VersionTime} value. + * @throws UaException if the value cannot be persisted. + */ + void save(ExpandedNodeId categoryId, UInteger value) throws UaException; + + /** + * Remove the persisted version of a category that no longer exists. + * + *

Called when a manager-created category is removed, so durable stores do not accumulate + * entries forever. Best-effort cleanup: a thrown exception is logged by the caller and the + * removal proceeds — a leftover entry is inert unless a category with the same NodeId is created + * again, in which case the version sequence resumes from it, which is harmless. The default + * implementation does nothing, for stores that prefer to keep (or externally expire) old entries. + * + * @param categoryId the namespace-URI-qualified ExpandedNodeId of the removed category. + * @throws UaException if the entry cannot be removed. + */ + default void delete(ExpandedNodeId categoryId) throws UaException {} +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/DeleteAliasesFromCategoryMethodImpl.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/DeleteAliasesFromCategoryMethodImpl.java new file mode 100644 index 0000000000..62a6e925c8 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/DeleteAliasesFromCategoryMethodImpl.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.eclipse.milo.opcua.sdk.server.Session; +import org.eclipse.milo.opcua.sdk.server.methods.Out; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType; +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; + +/** + * Network-facing {@code DeleteAliasesFromCategory} implementation: authorizes the calling session + * through the {@link AliasAuthorizationPolicy}, then delegates to the {@link AliasManager}'s + * per-entry mutation path, targeting the category Object the Method was called on. + * + *

The category is re-resolved from the call's Object NodeId on every invocation, so a call + * racing a category removal fails with {@code Bad_NodeIdUnknown} instead of observing stale state. + * + *

Call-level failures (a null {@code AliasNames} array, a {@code TargetNodes} array of a + * different length, operation count over the configured limit, denied authorization) fail the whole + * call; everything else is reported per entry through the {@code ErrorCodes} output, with one + * StatusCode per input entry. + */ +class DeleteAliasesFromCategoryMethodImpl + extends AliasNameCategoryType.DeleteAliasesFromCategoryMethod { + + private final AliasManager aliasManager; + private final AliasAuthorizationPolicy policy; + + DeleteAliasesFromCategoryMethodImpl( + UaMethodNode node, AliasManager aliasManager, AliasAuthorizationPolicy policy) { + + super(node); + + this.aliasManager = aliasManager; + this.policy = policy; + } + + @Override + protected void invoke( + InvocationContext context, + String[] aliasNames, + ExpandedNodeId[] targetNodes, + Out errorCodes) + throws UaException { + + Session session = context.getSession().orElse(null); + NodeId categoryId = context.getObjectId(); + + if (!policy.checkMutate(session, categoryId)) { + throw new UaException(StatusCodes.Bad_UserAccessDenied); + } + + errorCodes.set(aliasManager.deleteAliasEntries(categoryId, aliasNames, targetNodes)); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasMethodImpl.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasMethodImpl.java new file mode 100644 index 0000000000..856cc49286 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasMethodImpl.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.List; +import org.eclipse.milo.opcua.sdk.server.Session; +import org.eclipse.milo.opcua.sdk.server.methods.Out; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType; +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameDataType; +import org.jspecify.annotations.Nullable; + +/** + * Network-facing {@code FindAlias} implementation: authorizes the calling session through the + * {@link AliasAuthorizationPolicy}, then delegates to the {@link AliasSearchEngine}, searching from + * the category Object the Method was called on. + * + *

The category is re-resolved from the call's Object NodeId on every invocation, so a call + * racing a category removal fails with {@code Bad_NodeIdUnknown} instead of observing stale state. + */ +class FindAliasMethodImpl extends AliasNameCategoryType.FindAliasMethod { + + private final AliasSearchEngine engine; + private final AliasAuthorizationPolicy policy; + + FindAliasMethodImpl( + UaMethodNode node, AliasSearchEngine engine, AliasAuthorizationPolicy policy) { + + super(node); + + this.engine = engine; + this.policy = policy; + } + + @Override + protected void invoke( + InvocationContext context, + @Nullable String aliasNameSearchPattern, + @Nullable NodeId referenceTypeFilter, + Out aliasNodeList) + throws UaException { + + Session session = context.getSession().orElse(null); + NodeId categoryId = context.getObjectId(); + + String pattern = + FindMethodSupport.checkFindCall(policy, session, categoryId, aliasNameSearchPattern); + + List results = + engine.findAlias( + categoryId, + pattern, + referenceTypeFilter, + aliasNodeId -> policy.includeResult(session, aliasNodeId)); + + aliasNodeList.set(results.toArray(new AliasNameDataType[0])); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasVerboseMethodImpl.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasVerboseMethodImpl.java new file mode 100644 index 0000000000..812272c7a9 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindAliasVerboseMethodImpl.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.List; +import org.eclipse.milo.opcua.sdk.server.Session; +import org.eclipse.milo.opcua.sdk.server.methods.Out; +import org.eclipse.milo.opcua.sdk.server.model.objects.AliasNameCategoryType; +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.structured.AliasNameVerboseDataType; +import org.jspecify.annotations.Nullable; + +/** + * Network-facing {@code FindAliasVerbose} implementation: authorizes the calling session through + * the {@link AliasAuthorizationPolicy}, then delegates to the {@link AliasSearchEngine}, searching + * from the category Object the Method was called on. + * + *

The category is re-resolved from the call's Object NodeId on every invocation, so a call + * racing a category removal fails with {@code Bad_NodeIdUnknown} instead of observing stale state. + */ +class FindAliasVerboseMethodImpl extends AliasNameCategoryType.FindAliasVerboseMethod { + + private final AliasSearchEngine engine; + private final AliasAuthorizationPolicy policy; + + FindAliasVerboseMethodImpl( + UaMethodNode node, AliasSearchEngine engine, AliasAuthorizationPolicy policy) { + + super(node); + + this.engine = engine; + this.policy = policy; + } + + @Override + protected void invoke( + InvocationContext context, + @Nullable String aliasNameSearchPattern, + @Nullable NodeId referenceTypeFilter, + Out aliasNodeList) + throws UaException { + + Session session = context.getSession().orElse(null); + NodeId categoryId = context.getObjectId(); + + String pattern = + FindMethodSupport.checkFindCall(policy, session, categoryId, aliasNameSearchPattern); + + List results = + engine.findAliasVerbose( + categoryId, + pattern, + referenceTypeFilter, + aliasNodeId -> policy.includeResult(session, aliasNodeId)); + + aliasNodeList.set(results.toArray(new AliasNameVerboseDataType[0])); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindMethodSupport.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindMethodSupport.java new file mode 100644 index 0000000000..031f9f7fa9 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/FindMethodSupport.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.eclipse.milo.opcua.sdk.server.Session; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.jspecify.annotations.Nullable; + +/** + * The shared entry checks of the {@code FindAlias} and {@code FindAliasVerbose} Method + * implementations, kept in one place so the two implementations cannot drift. + */ +final class FindMethodSupport { + + private FindMethodSupport() {} + + /** + * Authorize a Find-family call and validate its search pattern. + * + * @param policy the policy to authorize against. + * @param session the calling session, or null for an internal call. + * @param categoryId the NodeId of the category the Method was called on. + * @param pattern the wire-decoded search pattern. + * @return the search pattern, now known non-null. + * @throws UaException with {@code Bad_UserAccessDenied} if the policy denies the call; {@code + * Bad_InvalidArgument} if the pattern is null. + */ + static String checkFindCall( + AliasAuthorizationPolicy policy, + @Nullable Session session, + NodeId categoryId, + @Nullable String pattern) + throws UaException { + + if (!policy.checkFind(session, categoryId)) { + throw new UaException(StatusCodes.Bad_UserAccessDenied); + } + + // The generated bridge passes wire-decoded input values through unchecked, so a null search + // pattern must be rejected here before it enters non-nullable signatures downstream; §6.3.2 + // maps invalid search strings to Bad_InvalidArgument. + if (pattern == null) { + throw new UaException(StatusCodes.Bad_InvalidArgument, "AliasNameSearchPattern is null"); + } + + return pattern; + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/InMemoryAliasVersionStore.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/InMemoryAliasVersionStore.java new file mode 100644 index 0000000000..380b4663a3 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/InMemoryAliasVersionStore.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; + +/** + * An {@link AliasVersionStore} that holds versions in memory only. + * + *

This store does not satisfy the Part 17 §6.3.1 persistence requirement: all versions + * are lost when the process exits, so {@code LastChange} regresses on every restart and Clients + * relying on it must discard their caches. It exists so the manager is usable with zero + * configuration in tests and demos; production applications must supply a durable implementation. + * + *

Thread-safe. + */ +public final class InMemoryAliasVersionStore implements AliasVersionStore { + + private final ConcurrentHashMap versions = new ConcurrentHashMap<>(); + + @Override + public Map load() { + return Map.copyOf(versions); + } + + @Override + public void save(ExpandedNodeId categoryId, UInteger value) { + versions.put(categoryId, value); + } + + @Override + public void delete(ExpandedNodeId categoryId) { + versions.remove(categoryId); + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java new file mode 100644 index 0000000000..5133bb421c --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/** + * Opt-in server-side support for OPC UA Part 17 Alias Names. + * + *

Alias names are the portable-configuration mechanism defined by OPC 10000-17: applications + * publish stable, human-meaningful names ({@code AliasNameType} Objects organized into {@code + * AliasNameCategoryType} folders) that resolve to one or more target Nodes through {@code AliasFor} + * References. Clients discover targets by calling the {@code FindAlias} and {@code + * FindAliasVerbose} Methods with a Part 4 {@code Like} wildcard pattern. + * + *

Milo's standard namespace already loads the complete Part 17 model surface — the {@code + * Aliases}, {@code TagVariables}, and {@code Topics} Objects and their {@code FindAlias} Method + * instances — but gives them no behavior. This package supplies the behavior, on an opt-in basis: + * nothing here runs unless an application constructs and starts an alias manager. + * + *

Ownership split

+ * + *

The framework owns the reusable Part 17 mechanics: Method handler binding, wildcard lookup + * with recursive category traversal, ReferenceType subtype filtering, deterministic result + * ordering, mutation validation, and {@code LastChange} version maintenance. The application owns + * everything vocabulary-shaped: which aliases and categories exist, the NodeIds and NodeManager + * they live in ({@link org.eclipse.milo.opcua.sdk.server.aliases.AliasCategoryConfig}), how version + * state is persisted ({@link org.eclipse.milo.opcua.sdk.server.aliases.AliasVersionStore}), and who + * may search or mutate over the network ({@link + * org.eclipse.milo.opcua.sdk.server.aliases.AliasAuthorizationPolicy}). Every SPI has a usable + * default, but the defaults are deliberately conservative: the in-memory version store does not + * survive restart, and the default policy denies all network mutation. + * + *

Lifecycle

+ * + *

The alias manager is an application-constructed lifecycle component, created with the {@link + * org.eclipse.milo.opcua.sdk.server.OpcUaServer} and an {@link + * org.eclipse.milo.opcua.sdk.server.aliases.AliasManagerConfig}, and started after server startup. + * On startup it registers its own AddressSpace fragment, binds handlers onto the standard {@code + * FindAlias} Method Nodes (restoring their executable flags), optionally materializes the Optional + * Method instances, and loads persisted {@code LastChange} values — a failed startup (handler + * conflict, NodeId collision, unreadable version store) rolls back anything already applied and + * leaves no trace. On shutdown it unbinds its handlers, deletes the Method Nodes it materialized, + * and unregisters its fragment: Nodes the manager hosts there (materialized Methods, alias Nodes + * created in standard or adopted categories) leave the AddressSpace with it, while category and + * alias Nodes hosted in application NodeManagers remain in place. Applications register their own + * categories and aliases through the manager's programmatic API after startup. + * + *

Data flow

+ * + *

The AddressSpace is the single source of truth; there is no shadow index. Lookup walks forward + * {@code Organizes} References from the called category at call time, recursing into {@code + * AliasNameCategoryType} instances and collecting {@code AliasNameType} instances, so aliases + * loaded from a NodeSet file or created by other components are found without registration. The + * trade-off is weak consistency under concurrent mutation — the same weak consistency Browse + * already has — and that out-of-band AddressSpace edits do not bump {@code LastChange}; mutations + * must flow through the manager (or be followed by an explicit {@code touch}) for version + * correctness. Out-of-band edits carry one further caveat: References recorded in only one + * direction (e.g. a category-side-only {@code Organizes} Reference loaded from a NodeSet without + * its inverse) are still found by search, which follows the forward direction, but are invisible to + * alias-side operations — organizing-category resolution during delete and ancestor discovery + * during version propagation both walk inverse References and miss such linkage. + * + *

Network mutation

+ * + *

The {@code AddAliasesToCategory} and {@code DeleteAliasesFromCategory} Methods are + * deny-by-default at two independent layers: the Method instances are only materialized (or, on + * adopted categories, bound) when configuration is enabled, and even then the {@link + * org.eclipse.milo.opcua.sdk.server.aliases.AliasAuthorizationPolicy} — whose default denies every + * session — must grant mutation per call. An authorized call is validated at two levels, per Part + * 17 §6.3.4/§6.3.5: call-level failures (null or non-parallel arrays, an invalid {@code + * TargetReferenceType}, operation counts over the configured limit) fail the whole call before any + * entry is processed, while everything else is reported through the per-entry {@code ErrorCodes} + * output. Entries are independent — a failed entry leaves its own state untouched and does not + * affect the others — and duplicate additions, including duplicates within one request, succeed + * without changing anything. All entries of a call apply under the manager's write lock, and {@code + * LastChange} is bumped once per affected category when the call completes. The programmatic API + * ({@code addAlias} / {@code deleteAlias}) shares the same validation and apply logic but is + * trusted application code: it bypasses the policy and fails fast on the first error. + * + *

The LastChange / VersionTime invariant

+ * + *

Part 17 §6.3.1 requires the root {@code Aliases} Object's {@code LastChange} Property to be + * monotonic and persisted across restart. Every mutation bumps the version of each affected + * category and of every ancestor category up to and including the root, computing {@code next = + * max(secondsSince2000(now), previous + 1)} so that clock rollback never produces a regression. New + * values are persisted through the {@link + * org.eclipse.milo.opcua.sdk.server.aliases.AliasVersionStore} before the mutation they + * describe is applied or the value is published: a failed save aborts the operation (or fails the + * entry) with {@code Bad_InternalError} and nothing changed, so no Client can ever observe an + * unpersisted {@code LastChange} value — after a restart the sequence therefore never repeats an + * observed value for different content, which would leave Client caches undetectably stale. + * Likewise a failed load at startup fails startup, because silently reset versions would violate + * the persistence contract undetectably. Categories whose {@code LastChange} Property does not + * exist (it is Optional per category) still participate in propagation; only the Property write is + * skipped. + */ +@NullMarked +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.jspecify.annotations.NullMarked; diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/namespaces/OpcUaNamespace.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/namespaces/OpcUaNamespace.java index 98ee8b59a3..53be3a6638 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/namespaces/OpcUaNamespace.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/namespaces/OpcUaNamespace.java @@ -48,11 +48,11 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime; import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject; import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; import org.eclipse.milo.opcua.stack.core.types.enumerated.RedundancySupport; import org.eclipse.milo.opcua.stack.core.types.enumerated.ServerState; -import org.eclipse.milo.opcua.stack.core.types.structured.Argument; import org.eclipse.milo.opcua.stack.core.types.structured.BuildInfo; import org.eclipse.milo.opcua.stack.core.types.structured.ServerStatusDataType; import org.eclipse.milo.opcua.stack.core.util.Namespaces; @@ -252,6 +252,29 @@ private void configureServerObject() { configureGetMonitoredItems(); configureResendData(); + configureAliasMethods(); + } + + private void configureAliasMethods() { + // The standard FindAlias Methods have no behavior unless an application installs an + // AliasManager. Marking them non-executable surfaces alias support as an absent feature + // instead of a callable Method that always fails with Bad_NotImplemented; an installed + // AliasManager restores both flags when it binds its handlers. UserExecutable is the flag + // access control enforces on Call. + NodeId[] findAliasNodeIds = { + NodeIds.Aliases_FindAlias, NodeIds.TagVariables_FindAlias, NodeIds.Topics_FindAlias + }; + + for (NodeId findAliasNodeId : findAliasNodeIds) { + UaNode node = getNodeManager().get(findAliasNodeId); + + if (node instanceof UaMethodNode methodNode) { + methodNode.setExecutable(false); + methodNode.setUserExecutable(false); + } else { + logger.warn("FindAlias UaMethodNode not found: {}", findAliasNodeId); + } + } } private void configureGetMonitoredItems() { @@ -295,18 +318,7 @@ private void configureConditionRefresh() { private static void configureMethodNode( UaMethodNode methodNode, Function f) { - T invocationHandler = f.apply(methodNode); - Argument[] inputArguments = invocationHandler.getInputArguments(); - Argument[] outputArguments = invocationHandler.getOutputArguments(); - - methodNode.setInvocationHandler(invocationHandler); - - if (inputArguments != null && inputArguments.length > 0) { - methodNode.setInputArguments(inputArguments); - } - if (outputArguments != null && outputArguments.length > 0) { - methodNode.setOutputArguments(outputArguments); - } + methodNode.bindInvocationHandler(f.apply(methodNode)); } private static class ConditionRefreshMethodImpl extends ConditionType.ConditionRefreshMethod { diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/nodes/UaMethodNode.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/nodes/UaMethodNode.java index abcd37b38b..2076fcdeec 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/nodes/UaMethodNode.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/nodes/UaMethodNode.java @@ -27,6 +27,7 @@ import org.eclipse.milo.opcua.sdk.core.nodes.Node; import org.eclipse.milo.opcua.sdk.core.nodes.ObjectNode; import org.eclipse.milo.opcua.sdk.server.NodeManager; +import org.eclipse.milo.opcua.sdk.server.methods.AbstractMethodInvocationHandler; import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler; import org.eclipse.milo.opcua.sdk.server.nodes.filters.AttributeFilter; import org.eclipse.milo.opcua.sdk.server.nodes.filters.AttributeFilterChain; @@ -191,6 +192,29 @@ public void setInvocationHandler(MethodInvocationHandler handler) { this.handler = handler; } + /** + * Set {@code handler} as this Method's invocation handler and publish the argument definitions it + * declares as the {@code InputArguments} and {@code OutputArguments} Properties. + * + *

An absent or empty argument declaration leaves the corresponding Property untouched, so a + * Method without inputs or outputs does not grow an empty argument Property. + * + * @param handler the handler to bind. + */ + public void bindInvocationHandler(AbstractMethodInvocationHandler handler) { + Argument[] inputArguments = handler.getInputArguments(); + Argument[] outputArguments = handler.getOutputArguments(); + + setInvocationHandler(handler); + + if (inputArguments != null && inputArguments.length > 0) { + setInputArguments(inputArguments); + } + if (outputArguments != null && outputArguments.length > 0) { + setOutputArguments(outputArguments); + } + } + /** * Get the value of the NodeVersion Property, if it exists. * diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasConfigValidationTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasConfigValidationTest.java new file mode 100644 index 0000000000..8182baa5c5 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasConfigValidationTest.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.eclipse.milo.opcua.sdk.server.Session; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Tests the validation and default-value contracts of the alias configuration surface: {@link + * AliasLimits}, {@link AliasTarget}, {@link AliasCategoryConfig}, and {@link + * AliasManagerConfig.Builder}. + */ +class AliasConfigValidationTest { + + @Nested + class AliasLimitsValidation { + + // The documented defaults gate Bad_ResponseTooLarge / Bad_InvalidArgument / + // Bad_TooManyOperations for every application that never customizes limits; + // changing them silently changes observable service behavior. + @Test + void defaultsAreTheDocumentedValues() { + assertEquals(new AliasLimits(1000, 512, 1000), AliasLimits.defaults()); + } + + // A zero or negative limit would make every FindAlias call fail (or disable the + // guard entirely, depending on comparison direction); such configs must be + // rejected at construction, not discovered at call time. + @ParameterizedTest + @MethodSource("nonPositiveLimits") + void nonPositiveLimitIsRejectedAtConstruction( + int maxResults, int maxPatternLength, int maxOperationsPerCall) { + assertThrows( + IllegalArgumentException.class, + () -> new AliasLimits(maxResults, maxPatternLength, maxOperationsPerCall)); + } + + static Stream nonPositiveLimits() { + return Stream.of( + Arguments.of(0, 512, 1000), + Arguments.of(-1, 512, 1000), + Arguments.of(1000, 0, 1000), + Arguments.of(1000, -1, 1000), + Arguments.of(1000, 512, 0), + Arguments.of(1000, 512, -1)); + } + } + + @Nested + class AliasTargetValidation { + + // Part 17 FindAliasVerbose semantics: a target with no serverUri resolves on the + // local Server; isLocal() drives both result classification and default ordering. + @Test + void targetWithoutServerUriIsLocalAndTargetWithServerUriIsRemote() { + var local = new AliasTarget(new NodeId(0, 1).expanded(), null, NodeIds.AliasFor); + var remote = + new AliasTarget(new NodeId(0, 1).expanded(), "urn:remote:server", NodeIds.AliasFor); + + assertTrue(local.isLocal()); + assertFalse(remote.isLocal()); + } + + // Design invariant: default result ordering is deterministic — local targets + // before remote ones, then by the NodeId's parseable *string* form (lexicographic, + // not numeric), so repeated calls yield byte-identical output. + @Test + void defaultOrderingPutsLocalBeforeRemoteThenSortsByParseableNodeIdString() { + var localI10 = new AliasTarget(new NodeId(0, 10).expanded(), null, NodeIds.AliasFor); + var localI2 = new AliasTarget(new NodeId(0, 2).expanded(), null, NodeIds.AliasFor); + var remoteI1 = + new AliasTarget(new NodeId(0, 1).expanded(), "urn:remote:server", NodeIds.AliasFor); + + var targets = new ArrayList<>(List.of(remoteI1, localI2, localI10)); + targets.sort(AliasTarget.DEFAULT_ORDERING); + + // "i=10" sorts before "i=2" lexicographically; the remote target sorts last + // despite having the smallest numeric identifier. + assertEquals(List.of(localI10, localI2, remoteI1), targets); + } + } + + @Nested + class AliasManagerConfigBuilderDefaults { + + private final AliasManagerConfig config = AliasManagerConfig.builder().build(); + + // Search-allowed is the documented default because FindAlias reveals exactly what + // Browse on the alias hierarchy already reveals; both session-bearing and + // internal (null session) calls must be allowed. + @Test + void defaultPolicyAllowsFindForSessionAndForInternalCalls() { + AliasAuthorizationPolicy policy = config.getAuthorizationPolicy(); + + assertTrue(policy.checkFind(mock(Session.class), NodeIds.Aliases)); + assertTrue(policy.checkFind(null, NodeIds.Aliases)); + } + + // Deny-by-default mutation is a security invariant: enabling network mutation + // must require an explicit policy grant, and even internal callers go through + // the programmatic API rather than the network policy. + @Test + void defaultPolicyDeniesMutateForSessionAndForInternalCalls() { + AliasAuthorizationPolicy policy = config.getAuthorizationPolicy(); + + assertFalse(policy.checkMutate(mock(Session.class), NodeIds.Aliases)); + assertFalse(policy.checkMutate(null, NodeIds.Aliases)); + } + + // Design decision: no per-target authorization filtering by default — alias + // visibility is delegated to the Server's general permission model. + @Test + void defaultPolicyIncludesEveryMatchedAliasInResults() { + AliasAuthorizationPolicy policy = config.getAuthorizationPolicy(); + + assertTrue(policy.includeResult(mock(Session.class), new NodeId(1, "alias"))); + assertTrue(policy.includeResult(null, new NodeId(1, "alias"))); + } + + // The Builder and the search engine must share the single DEFAULT_ORDERING + // definition so default output stays byte-identical across both paths. + @Test + void defaultTargetOrderingIsTheSharedDefaultOrderingInstance() { + assertSame(AliasTarget.DEFAULT_ORDERING, config.getTargetOrdering()); + } + + @Test + void defaultLimitsAreTheDocumentedDefaults() { + assertEquals(AliasLimits.defaults(), config.getLimits()); + } + + // The zero-configuration default store is deliberately in-memory: usable for + // tests and demos, while the Javadoc warns it does not satisfy Part 17 §6.3.1. + @Test + void defaultVersionStoreIsInMemory() { + assertInstanceOf(InMemoryAliasVersionStore.class, config.getVersionStore()); + } + + // Materialized Method Nodes must not claim ns=0 identifiers, which are reserved + // for the standard NodeSet; the default allocates from namespace index 1. + @Test + void defaultNodeNamespaceIndexIsOne() { + assertEquals(UShort.valueOf(1), config.getNodeNamespaceIndex()); + } + + // FindAliasVerbose and the mutation Methods are Optional per Part 17; they must + // only appear in the AddressSpace when the application explicitly enables them. + @Test + void optionalMethodBehaviorsAreDisabledByDefault() { + assertFalse(config.isFindAliasVerboseEnabled()); + assertFalse(config.isConfigurationEnabled()); + } + } +} diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManagerTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManagerTest.java new file mode 100644 index 0000000000..a218ded927 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/AliasVersionManagerTest.java @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.eclipse.milo.opcua.sdk.server.AddressSpaceManager; +import org.eclipse.milo.opcua.sdk.server.OpcUaServer; +import org.eclipse.milo.opcua.stack.core.NamespaceTable; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.junit.jupiter.api.Test; + +/** + * Tests the {@code VersionTime} computation and persistence behavior of {@link AliasVersionManager} + * in isolation, against a Server whose AddressSpace is empty: no ancestor categories are discovered + * and no {@code LastChange} Property Nodes exist, so only the version arithmetic and store + * interaction are exercised. + */ +class AliasVersionManagerTest { + + /** The {@code VersionTime} epoch, 2000-01-01T00:00:00Z, as a Unix epoch second (Part 4 §7.43). */ + private static final long VERSION_TIME_EPOCH_SECOND = 946684800L; + + /** A VersionTime value far in the future (~year 2126), safely ahead of any test-run clock. */ + private static final long FAR_FUTURE_VERSION = 4_000_000_000L; + + private static final String TEST_NAMESPACE_URI = "urn:eclipse:milo:test"; + + private static OpcUaServer serverWithEmptyAddressSpace() { + OpcUaServer server = mock(OpcUaServer.class); + AddressSpaceManager addressSpaceManager = mock(AddressSpaceManager.class); + + var namespaceTable = new NamespaceTable(); + namespaceTable.add(TEST_NAMESPACE_URI); + + when(server.getAddressSpaceManager()).thenReturn(addressSpaceManager); + when(server.getNamespaceTable()).thenReturn(namespaceTable); + when(addressSpaceManager.getManagedReferences(any(NodeId.class), any())).thenReturn(List.of()); + when(addressSpaceManager.getManagedNode(any(NodeId.class))).thenReturn(Optional.empty()); + + return server; + } + + private static long secondsSince2000() { + return Instant.now().getEpochSecond() - VERSION_TIME_EPOCH_SECOND; + } + + // Part 4 §7.43: VersionTime is seconds since 2000-01-01T00:00:00Z. A category's + // first bump must produce the current wall-clock VersionTime, not a counter + // starting at 1, so Clients can compare it against real time. + @Test + void firstTouchSeedsCategoryWithCurrentVersionTime() throws UaException { + var manager = + new AliasVersionManager(serverWithEmptyAddressSpace(), new InMemoryAliasVersionStore()); + var categoryId = new NodeId(1, "category"); + + long before = secondsSince2000(); + manager.touch(categoryId); + long after = secondsSince2000(); + + long value = manager.get(categoryId).orElseThrow().longValue(); + assertTrue(before <= value && value <= after, "expected current VersionTime, got " + value); + } + + // Design invariant: next = max(secondsSince2000(now), previous + 1). Two mutations + // within the same wall-clock second must still produce distinct, increasing + // LastChange values, or Clients caching by LastChange would miss the second change. + @Test + void consecutiveTouchesProduceStrictlyIncreasingValues() throws UaException { + var manager = + new AliasVersionManager(serverWithEmptyAddressSpace(), new InMemoryAliasVersionStore()); + var categoryId = new NodeId(1, "category"); + + manager.touch(categoryId); + long first = manager.get(categoryId).orElseThrow().longValue(); + + manager.touch(categoryId); + long second = manager.get(categoryId).orElseThrow().longValue(); + + assertTrue(second > first, "expected " + second + " > " + first); + } + + // The previous+1 arm of the formula protects monotonicity under clock rollback: + // when the persisted version is ahead of the clock (e.g. the clock regressed + // across a restart), the next value must advance by one, never regress to "now". + @Test + void touchAdvancesByOneWhenPersistedVersionIsAheadOfTheClock() throws UaException { + var store = new InMemoryAliasVersionStore(); + var categoryId = new NodeId(1, "category"); + var storeKey = ExpandedNodeId.of(TEST_NAMESPACE_URI, "category"); + store.save(storeKey, uint(FAR_FUTURE_VERSION)); + + var manager = new AliasVersionManager(serverWithEmptyAddressSpace(), store); + + // loadPersisted resolves the URI-qualified store keys against the namespace table and + // seeds the in-memory state. + Map loaded = manager.loadPersisted(); + assertEquals(Map.of(categoryId, uint(FAR_FUTURE_VERSION)), loaded); + assertEquals(Optional.of(uint(FAR_FUTURE_VERSION)), manager.get(categoryId)); + + manager.touch(categoryId); + + assertEquals(Optional.of(uint(FAR_FUTURE_VERSION + 1)), manager.get(categoryId)); + + // Part 17 §6.3.1: every bump persists the new value through the store, under the same + // URI-qualified key it was loaded from. + assertEquals(uint(FAR_FUTURE_VERSION + 1), store.load().get(storeKey)); + } + + // A store entry whose namespace URI is not registered cannot belong to any live + // category; loadPersisted must skip it rather than fail startup, leaving the inert + // entry in the store untouched. + @Test + void loadPersistedSkipsEntriesWithUnregisteredNamespaceUris() throws UaException { + var store = new InMemoryAliasVersionStore(); + store.save(ExpandedNodeId.of("urn:not:registered", "category"), uint(FAR_FUTURE_VERSION)); + + var manager = new AliasVersionManager(serverWithEmptyAddressSpace(), store); + + assertTrue(manager.loadPersisted().isEmpty()); + } + + // Save-before-mutate: a version is persisted by prepare BEFORE the mutation it + // describes is applied, and a failed save aborts with the version state unchanged. + // Advancing in memory despite a failed save would let LastChange re-produce an + // observed value after a restart — undetectable staleness for Client caches. + @Test + void failedSaveFailsPrepareWithoutAdvancingTheVersion() { + var failingStore = + new AliasVersionStore() { + @Override + public Map load() { + return Map.of(); + } + + @Override + public void save(ExpandedNodeId categoryId, UInteger value) throws UaException { + throw new UaException(StatusCodes.Bad_ResourceUnavailable, "save failed"); + } + }; + + var manager = new AliasVersionManager(serverWithEmptyAddressSpace(), failingStore); + var categoryId = new NodeId(1, "category"); + + UaException e = assertThrows(UaException.class, () -> manager.prepare(List.of(categoryId))); + + assertEquals(StatusCodes.Bad_InternalError, e.getStatusCode().value()); + assertEquals(Optional.empty(), manager.get(categoryId), "version must not advance"); + + // Publishing after the failed prepare must be a harmless no-op. + manager.publishPending(); + assertEquals(Optional.empty(), manager.get(categoryId)); + } + + // The two-phase contract: prepare persists immediately, publishPending only writes + // Property Nodes (none exist here) and clears the pending set, so a category is + // prepared at most once between publishes no matter how many entries touch it. + @Test + void prepareIsIdempotentPerCategoryUntilPublished() throws UaException { + var manager = + new AliasVersionManager(serverWithEmptyAddressSpace(), new InMemoryAliasVersionStore()); + var categoryId = new NodeId(1, "category"); + + manager.prepare(List.of(categoryId)); + long first = manager.get(categoryId).orElseThrow().longValue(); + + manager.prepare(List.of(categoryId)); + assertEquals(first, manager.get(categoryId).orElseThrow().longValue()); + + manager.publishPending(); + + manager.prepare(List.of(categoryId)); + long second = manager.get(categoryId).orElseThrow().longValue(); + assertTrue(second > first, "expected " + second + " > " + first); + } +} diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/InMemoryAliasVersionStoreTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/InMemoryAliasVersionStoreTest.java new file mode 100644 index 0000000000..3e1b315951 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/InMemoryAliasVersionStoreTest.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server.aliases; + +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger; +import org.eclipse.milo.opcua.stack.core.util.Namespaces; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +/** Tests the {@link AliasVersionStore} contract as implemented by the in-memory default store. */ +class InMemoryAliasVersionStoreTest { + + private static final String TEST_NAMESPACE_URI = "urn:eclipse:milo:test"; + + // The store's only job is preserving LastChange versions between save and load; + // repeated saves for the same category must yield the latest value. + @Test + void savedVersionsRoundTripThroughLoadWithLatestValueWinning() throws Exception { + var store = new InMemoryAliasVersionStore(); + ExpandedNodeId aliases = ExpandedNodeId.of(Namespaces.OPC_UA, uint(23470)); + ExpandedNodeId tagVariables = ExpandedNodeId.of(Namespaces.OPC_UA, uint(23479)); + + store.save(aliases, uint(100)); + store.save(tagVariables, uint(200)); + store.save(aliases, uint(101)); + + assertEquals(Map.of(aliases, uint(101), tagVariables, uint(200)), store.load()); + } + + // AliasVersionStore.load is called once at startup to seed the manager's state; + // a live or mutable view would let later saves (or callers) corrupt that seed. + @Test + void loadReturnsAnImmutableSnapshotUnaffectedByLaterSaves() throws Exception { + var store = new InMemoryAliasVersionStore(); + ExpandedNodeId categoryId = ExpandedNodeId.of(TEST_NAMESPACE_URI, "category"); + + store.save(categoryId, uint(1)); + Map loaded = store.load(); + + assertThrows( + UnsupportedOperationException.class, + () -> loaded.put(ExpandedNodeId.of(TEST_NAMESPACE_URI, "other"), uint(2))); + + store.save(categoryId, uint(2)); + assertEquals(uint(1), loaded.get(categoryId), "snapshot must not reflect later saves"); + } + + @Test + void loadIsEmptyWhenNothingHasBeenSaved() throws Exception { + assertTrue(new InMemoryAliasVersionStore().load().isEmpty()); + } + + // Removed categories must not leak entries in the store forever (AliasVersionStore.delete is + // called on category removal); deleting an absent key is a harmless no-op. + @Test + void deleteRemovesTheEntryAndDeletingAnAbsentKeyIsANoOp() throws Exception { + var store = new InMemoryAliasVersionStore(); + ExpandedNodeId categoryId = ExpandedNodeId.of(TEST_NAMESPACE_URI, "category"); + + store.save(categoryId, uint(1)); + store.delete(categoryId); + assertTrue(store.load().isEmpty()); + + store.delete(categoryId); + assertTrue(store.load().isEmpty()); + } + + // The store advertises thread safety; concurrent saves to distinct categories must + // not lose entries. Distinct keys keep the expected end state deterministic. + @Test + void concurrentSavesToDistinctCategoriesAllSurvive() throws Exception { + var store = new InMemoryAliasVersionStore(); + + int threadCount = 4; + int savesPerThread = 250; + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + var tasks = new ArrayList>(); + for (int t = 0; t < threadCount; t++) { + int thread = t; + tasks.add( + () -> { + for (int i = 0; i < savesPerThread; i++) { + int key = thread * savesPerThread + i; + store.save(ExpandedNodeId.of(TEST_NAMESPACE_URI, "category-" + key), uint(key)); + } + return null; + }); + } + + List> futures = executor.invokeAll(tasks); + for (Future<@Nullable Void> future : futures) { + future.get(); // propagate any save failure + } + } finally { + executor.shutdown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } + + Map loaded = store.load(); + assertEquals(threadCount * savesPerThread, loaded.size()); + for (int key = 0; key < threadCount * savesPerThread; key++) { + assertEquals(uint(key), loaded.get(ExpandedNodeId.of(TEST_NAMESPACE_URI, "category-" + key))); + } + } +} diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java new file mode 100644 index 0000000000..96db826991 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/aliases/package-info.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/** + * Tests for the Part 17 Alias Names support in {@link org.eclipse.milo.opcua.sdk.server.aliases}. + */ +@NullMarked +package org.eclipse.milo.opcua.sdk.server.aliases; + +import org.jspecify.annotations.NullMarked;