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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ Consequences:

### Fixed

- **WQL array properties keep all their elements** (#189). A WMI array (`IPAddress`,
`DefaultIPGateway`, `Capabilities`, ...) used to yield only its last element, without any
error. Its elements are now joined with `|` (`"192.0.2.10|fe80::1"`); the new
`WinRMClient.Builder.arraySeparator(String)` changes the separator. The `WqlRow` Javadoc now
says what the code does: a WMI `NULL` is an empty string, `null` means "no such property".

- **Streaming terminals now report protocol failures as `WinRMClientException`** (#188).
`WqlRequest.stream()`, `CommandRequest.start()`, `RemoteFile.openStream()`/`openReader()`,
`RemoteDirectoryListing.stream()` and the closing of a `RemoteProcess` let raw
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/org/metricshub/winrm/WinRMClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ public static final class Builder {
private boolean trustAllCertificates;
private int consoleCodePage;
private boolean loadUserProfile;
private String arraySeparator = LightWinRMService.DEFAULT_ARRAY_SEPARATOR;
private SSLContext sslContext;
private Duration timeout = DEFAULT_TIMEOUT;
private int retries;
Expand Down Expand Up @@ -609,6 +610,20 @@ public Builder loadUserProfile() {
return this;
}

/**
* Set the string that joins the elements of a WMI array property in a WQL row, such as
* {@code IPAddress} in {@code Win32_NetworkAdapterConfiguration}. Default: {@code |}
* ({@code "192.0.2.10|fe80::1"}).
*
* @param arraySeparator the separator (may be empty, not {@code null})
* @return this builder
*/
public Builder arraySeparator(final String arraySeparator) {
Utils.checkNonNull(arraySeparator, "arraySeparator");
this.arraySeparator = arraySeparator;
return this;
}

/**
* Build the client. This does not connect yet: the connection is established and
* authenticated by the first operation.
Expand Down Expand Up @@ -656,6 +671,7 @@ public WinRMClient build() {
trustAllCertificates,
consoleCodePage,
loadUserProfile,
arraySeparator,
retries,
toMillis(retryDelay)
);
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/org/metricshub/winrm/WqlRow.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ public final class WqlRow {
* back to a case-insensitive match — WMI property names are case-insensitive.
*
* @param property the property name
* @return the property value, or {@code null} when the property is absent or null
* @return the property value, or {@code null} when the row has no such property (a WMI
* {@code NULL} is an empty string)
*/
public Object get(final String property) {
Utils.checkNonNull(property, "property");
Expand All @@ -65,7 +66,8 @@ public Object get(final String property) {
* Get the value of a property as a string. Same lookup semantics as {@link #get(String)}.
*
* @param property the property name
* @return the property value as a string, or {@code null} when the property is absent or null
* @return the property value as a string, or {@code null} when the row has no such property
* (a WMI {@code NULL} is an empty string)
*/
public String string(final String property) {
final Object value = get(property);
Expand Down
70 changes: 69 additions & 1 deletion src/main/java/org/metricshub/winrm/light/LightWinRMService.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
*/
public final class LightWinRMService implements WindowsRemoteExecutor {

/** The default string joining the elements of a WMI array property in a WQL row. */
public static final String DEFAULT_ARRAY_SEPARATOR = "|";

private final WinRMEndpoint winRMEndpoint;
private final WsmanClient client;
private final AtomicBoolean closed = new AtomicBoolean(false);
Expand Down Expand Up @@ -211,7 +214,67 @@ public static LightWinRMService createInstance(
/**
* Create a light WinRM executor that may delegate the caller's Kerberos credentials to the host,
* so remote commands can authenticate onward as the caller (the second hop), and may load the
* user profile in the command shell.
* user profile in the command shell. WMI array properties are joined with
* {@link #DEFAULT_ARRAY_SEPARATOR}.
*
* @param winRMEndpoint endpoint with credentials (mandatory)
* @param timeout timeout in milliseconds (must be > 0)
* @param ticketCache Kerberos ticket cache path (used by the Kerberos scheme; {@code null} logs
* in with the password)
* @param authentications requested authentication schemes, tried in order (NTLM, Kerberos, and/or Basic);
* {@code null}/empty means NTLM only
* @param allowDelegation whether Kerberos forwards the caller's ticket-granting ticket to the
* host (which must then be forwardable); requires Kerberos among {@code authentications}
* @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname
* verification stays on); {@code null} uses the default configuration
* @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every
* server certificate and skip hostname verification — insecure, testing only
* @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001,
* which makes command output UTF-8 whatever the remote locale
* @param loadUserProfile whether the command shell loads the user profile (registry hive,
* per-user environment variables); {@code false} is the historical behavior
* @param connectRetries how many times one round trip may re-attempt to connect and authenticate
* (must be >= 0); 0 keeps the historical fail-fast behavior
* @param retryDelay the pause in milliseconds before each retry (must be >= 0)
* @return a new {@code LightWinRMService}
* @throws WinRMException on invalid arguments or an unsupported authentication request
*/
// CPD-OFF — a compatibility overload: its parameter list is the next overload's minus the
// separator, and reordering the parameters to fool the detector would break callers.
public static LightWinRMService createInstance(
final WinRMEndpoint winRMEndpoint,
final long timeout,
final java.nio.file.Path ticketCache,
final List<AuthenticationEnum> authentications,
final boolean allowDelegation,
final SSLContext sslContext,
final boolean trustAllCertificates,
final int consoleCodePage,
final boolean loadUserProfile,
final int connectRetries,
final long retryDelay
) throws WinRMException {
return createInstance(
winRMEndpoint,
timeout,
ticketCache,
authentications,
allowDelegation,
sslContext,
trustAllCertificates,
consoleCodePage,
loadUserProfile,
DEFAULT_ARRAY_SEPARATOR,
connectRetries,
retryDelay
);
// CPD-ON
}

/**
* Create a light WinRM executor that may delegate the caller's Kerberos credentials to the host,
* so remote commands can authenticate onward as the caller (the second hop), may load the
* user profile in the command shell, and joins WMI array properties with a custom separator.
*
* @param winRMEndpoint endpoint with credentials (mandatory)
* @param timeout timeout in milliseconds (must be &gt; 0)
Expand All @@ -229,6 +292,8 @@ public static LightWinRMService createInstance(
* which makes command output UTF-8 whatever the remote locale
* @param loadUserProfile whether the command shell loads the user profile (registry hive,
* per-user environment variables); {@code false} is the historical behavior
* @param arraySeparator the string joining the elements of a WMI array property in a WQL row;
* see {@link #DEFAULT_ARRAY_SEPARATOR}
* @param connectRetries how many times one round trip may re-attempt to connect and authenticate
* (must be &gt;= 0); 0 keeps the historical fail-fast behavior
* @param retryDelay the pause in milliseconds before each retry (must be &gt;= 0)
Expand All @@ -245,10 +310,12 @@ public static LightWinRMService createInstance(
final boolean trustAllCertificates,
final int consoleCodePage,
final boolean loadUserProfile,
final String arraySeparator,
Comment thread
bertysentry marked this conversation as resolved.
final int connectRetries,
final long retryDelay
) throws WinRMException {
Utils.checkNonNull(winRMEndpoint, "winRMEndpoint");
Utils.checkNonNull(arraySeparator, "arraySeparator");
Utils.checkArgumentNotZeroOrNegative(timeout, "timeout");
if (connectRetries < 0) {
throw new IllegalArgumentException("connectRetries must not be negative.");
Expand Down Expand Up @@ -298,6 +365,7 @@ public static LightWinRMService createInstance(
winRMEndpoint.getRawUsername(),
consoleCodePage,
loadUserProfile,
arraySeparator,
connectRetries,
retryDelay
);
Expand Down
36 changes: 27 additions & 9 deletions src/main/java/org/metricshub/winrm/light/WsmanClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ final class WsmanClient implements AutoCloseable {
private final long timeoutMs;
private final int consoleCodePage;
private final boolean loadUserProfile;
private final String arraySeparator;
private final String url;
private final String rawUsername;
private final AuthScheme auth;
Expand Down Expand Up @@ -189,12 +190,14 @@ private static void checkNotCancelled() throws InterruptedException {
final String rawUsername,
final int consoleCodePage,
final boolean loadUserProfile,
final String arraySeparator,
final int connectRetries,
final long retryDelayMs
) {
this.timeoutMs = timeoutMs;
this.consoleCodePage = consoleCodePage;
this.loadUserProfile = loadUserProfile;
this.arraySeparator = arraySeparator;
this.connectRetries = connectRetries;
this.retryDelayMs = retryDelayMs;
// A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext.
Expand Down Expand Up @@ -371,7 +374,7 @@ private WqlEnumeration(
private void ingest(final Document doc) {
page = new ArrayList<>();
cursor = 0;
collectItems(doc, page);
collectItems(doc, page, arraySeparator);
endOfSequence = hasEnumerationElement(doc, "EndOfSequence");
// Pull only while the server hands back a context (matching the CXF backend).
context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext");
Expand Down Expand Up @@ -1304,31 +1307,46 @@ private static String textNS(final Document doc, final String namespace, final S
return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null;
}

static void collectItems(final Document doc, final List<Map<String, String>> rows) {
static void collectItems(final Document doc, final List<Map<String, String>> rows, final String arraySeparator) {
// The Items wrapper comes in the WS-Enumeration namespace (EnumerateResponse) or the WSMan
// namespace (PullResponse) depending on the operation; accept both, like the CXF backend, and
// nothing else — a WMI property or class named "Items" must not be mistaken for the wrapper.
collectRows(doc.getElementsByTagNameNS(WS_ENUMERATION_NS, "Items"), rows);
collectRows(doc.getElementsByTagNameNS(WSMAN_NS, "Items"), rows);
collectRows(doc.getElementsByTagNameNS(WS_ENUMERATION_NS, "Items"), rows, arraySeparator);
collectRows(doc.getElementsByTagNameNS(WSMAN_NS, "Items"), rows, arraySeparator);
}

private static void collectRows(final NodeList items, final List<Map<String, String>> rows) {
private static void collectRows(
final NodeList items,
final List<Map<String, String>> rows,
final String arraySeparator
) {
for (int i = 0; i < items.getLength(); i++) {
final NodeList instances = items.item(i).getChildNodes();
for (int j = 0; j < instances.getLength(); j++) {
final Node instance = instances.item(j);
if (instance.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
final Map<String, String> row = new LinkedHashMap<>();
// A WMI array comes back as sibling elements sharing one name: join them. Built with
// StringBuilders so a large array (SMBIOS raw tables: tens of thousands of bytes) is
// appended in linear time rather than re-copied on every element.
final Map<String, StringBuilder> values = new LinkedHashMap<>();
final NodeList props = instance.getChildNodes();
for (int k = 0; k < props.getLength(); k++) {
final Node prop = props.item(k);
if (prop.getNodeType() == Node.ELEMENT_NODE) {
row.put(((Element) prop).getLocalName(), prop.getTextContent());
if (prop.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
final StringBuilder value = values.get(((Element) prop).getLocalName());
if (value == null) {
values.put(((Element) prop).getLocalName(), new StringBuilder(prop.getTextContent()));
} else {
value.append(arraySeparator).append(prop.getTextContent());
}
}
if (!row.isEmpty()) {
if (!values.isEmpty()) {
final Map<String, String> row = new LinkedHashMap<>();
values.forEach((name, value) -> row.put(name, value.toString()));
rows.add(row);
}
}
Expand Down
1 change: 1 addition & 0 deletions src/site/markdown/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ Besides the host name passed to `builder(...)`, only `credentials(...)` is manda
| `namespace(String)` | `ROOT\CIMV2` | [Choosing a namespace](wql.html#choosing-a-namespace) |
| `loadUserProfile()` | not loaded | [Loading the user profile](commands.html#loading-the-user-profile) |
| `consoleCodePage(int)` | 65001 (UTF-8) | [Input encoding](commands.html#input-encoding) |
| `arraySeparator(String)` | `\|` | [Reading the result](wql.html#reading-the-result) |

## Where to go next

Expand Down
6 changes: 5 additions & 1 deletion src/site/markdown/wql.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Each [`WqlRow`](apidocs/org/metricshub/winrm/WqlRow.html) exposes the instance p
| Method | Returns | Description |
| --- | --- | --- |
| `string(String)` | `String` | The property value, or `null` when the row has no such property. |
| `get(String)` | `Object` | The same value (currently always a `String`). |
| `get(String)` | `Object` | The same value (always a `String`). |
| `asMap()` | `Map<String, Object>` | All properties, in server order (unmodifiable). |

Property lookup is **case-insensitive**, matching WMI semantics: `row.string("name")` and
Expand All @@ -118,6 +118,10 @@ Property lookup is **case-insensitive**, matching WMI semantics: `row.string("na
Values are the text WinRM sends, unconverted: parse numbers, booleans and dates yourself. A WMI
`NULL` comes back as an empty string.

A WMI **array** property (`IPAddress` in `Win32_NetworkAdapterConfiguration`, `Capabilities` in
`Win32_DiskDrive`, ...) comes back as a single string, its elements joined with `|`:
`"192.0.2.10|fe80::1"`. The builder's `arraySeparator(String)` changes the separator.

### Column order and case

These rules apply to `WqlResult.columns()`; each row's `asMap()` keeps the server's order, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ void closeWhileOperationInFlightStillErasesTheBasicCredential() throws Exception
USERNAME,
65001,
false,
"|",
0,
0L
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,42 @@ void itemsCollectedFromBothNamespaceVariants() throws Exception {

for (final Document doc : List.of(wsenItems, wsmanItems)) {
final List<Map<String, String>> rows = new ArrayList<>();
WsmanClient.collectItems(doc, rows);
WsmanClient.collectItems(doc, rows, "|");
assertEquals(1, rows.size());
assertEquals("Spooler", rows.get(0).get("Name"));
assertEquals("Running", rows.get(0).get("State"));
}
}

@Test
void arrayPropertyElementsAreJoinedWithTheSeparator() throws Exception {
// WS-Management sends a WMI array as sibling elements sharing one name; a WMI NULL is xsi:nil.
final Document doc = parse(
"<s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\">" +
"<s:Body><wsen:PullResponse xmlns:wsen=\"" +
WSEN +
"\">" +
"<wsman:Items xmlns:wsman=\"" +
WSMAN +
"\">" +
"<p:Cfg xmlns:p=\"http://schemas.microsoft.com/wbem/wsman/1/wmi/root/cimv2/Cfg\"" +
" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">" +
"<p:IPAddress>192.0.2.10</p:IPAddress>" +
"<p:Caption>eth0</p:Caption>" +
"<p:IPAddress>fe80::1</p:IPAddress>" +
"<p:DefaultIPGateway xsi:nil=\"true\"/>" +
"</p:Cfg>" +
"</wsman:Items>" +
"</wsen:PullResponse></s:Body></s:Envelope>"
);
final List<Map<String, String>> rows = new ArrayList<>();
WsmanClient.collectItems(doc, rows, ", ");
assertEquals(1, rows.size());
assertEquals("192.0.2.10, fe80::1", rows.get(0).get("IPAddress"));
assertEquals("eth0", rows.get(0).get("Caption"));
assertEquals("", rows.get(0).get("DefaultIPGateway"));
}

@Test
void wmiPropertyNamedItemsIsNotMistakenForTheWrapper() throws Exception {
// A structured WMI property called Items (in the class's namespace) must not be read as a
Expand All @@ -140,7 +169,7 @@ void wmiPropertyNamedItemsIsNotMistakenForTheWrapper() throws Exception {
"</wsen:PullResponse></s:Body></s:Envelope>"
);
final List<Map<String, String>> rows = new ArrayList<>();
WsmanClient.collectItems(doc, rows);
WsmanClient.collectItems(doc, rows, "|");
assertEquals(1, rows.size());
assertEquals("real-row", rows.get(0).get("Name"));
}
Expand Down
Loading