diff --git a/pom.xml b/pom.xml index 22e0585..fdef533 100644 --- a/pom.xml +++ b/pom.xml @@ -112,12 +112,6 @@ junit-jupiter-engine test - - org.mockito - mockito-inline - 5.2.0 - test - @@ -213,13 +207,16 @@ maven-pmd-plugin - 3.26.0 + 3.28.0 verify @@ -262,13 +259,15 @@ com.github.spotbugs spotbugs-maven-plugin - 4.9.3.0 + 4.10.3.0 verify @@ -355,9 +354,18 @@ 3.5.6 - + + + com.github.spotbugs + spotbugs-maven-plugin + 4.10.3.0 + + + maven-pmd-plugin + 3.28.0 true ${project.build.sourceEncoding} diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 4fb44f8..698c31f 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -1,16 +1,34 @@ package org.metricshub.winrm.cli; +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + 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.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueCommandExchange; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueEnumeration; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellCreation; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; +import static org.metricshub.winrm.light.FakeWsmanResponses.instance; import java.io.ByteArrayOutputStream; import java.io.PrintStream; @@ -22,10 +40,11 @@ import java.util.Map; import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; +import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WindowsRemoteCommandResult; -import org.metricshub.winrm.WindowsRemoteProcessUtils; +import org.metricshub.winrm.light.FakeWsmanServer; import org.metricshub.winrm.light.LightWinRMService; +import org.metricshub.winrm.service.WinRMEndpoint; class WinRmCliTest { @@ -238,21 +257,44 @@ void honorsAnAmbientInsecureTlsProperty() throws Exception { @Test void decodesCommandOutputUsingTheRemoteWindowsCodePage() throws Exception { - final LightWinRMService service = mock(LightWinRMService.class); final Charset windowsCharset = Charset.forName("windows-1251"); - final WindowsRemoteCommandResult expected = new WindowsRemoteCommandResult("Результат", "", 0.0f, 0); - final long timeout = 1_234L; - when(service.executeCommand("whoami", null, windowsCharset, timeout)).thenReturn(expected); - - try (MockedStatic processUtils = mockStatic(WindowsRemoteProcessUtils.class)) { - processUtils - .when(() -> WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout)) - .thenReturn(windowsCharset); - final WinRmCli.LightRemoteOperations remote = new WinRmCli.LightRemoteOperations(service); - - assertSame(expected, remote.executeCommand("whoami", timeout)); - processUtils.verify(() -> WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout)); - verify(service).executeCommand(eq("whoami"), isNull(), eq(windowsCharset), eq(timeout)); + final long timeout = 30_000L; + + // End to end against the in-process WSMan server: the remote reports Windows code page + // 1251 and the command output arrives in that encoding — the CLI must query the code page + // and decode the stream bytes with it, or the Cyrillic output turns into mojibake. + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueEnumeration(server, instance("Win32_OperatingSystem", "CodeSet", "1251")); + enqueueShellCreation(server); + enqueueCommandExchange(server, "Результат".getBytes(windowsCharset), new byte[0], 0); + enqueueShellDeletion(server); + + final WinRMEndpoint endpoint = new WinRMEndpoint( + WinRMHttpProtocolEnum.HTTP, + "127.0.0.1", + server.port(), + "FAKE\\user", + "secret".toCharArray(), + null + ); + final WindowsRemoteCommandResult result; + try ( + WinRmCli.LightRemoteOperations remote = new WinRmCli.LightRemoteOperations( + LightWinRMService.createInstance(endpoint, timeout, null, null) + )) { + result = remote.executeCommand("whoami", timeout); + } + + assertEquals("Результат", result.getStdout()); + assertEquals(0, result.getStatusCode()); + + // The decoding charset really came from the remote code-page query + assertTrue( + server + .decryptedRequests() + .stream() + .anyMatch(request -> request.contains("SELECT CodeSet FROM Win32_OperatingSystem")) + ); } } diff --git a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java index 20645e5..2852806 100644 --- a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java +++ b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java @@ -1,5 +1,25 @@ package org.metricshub.winrm.command; +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; @@ -8,36 +28,47 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS; import static org.metricshub.winrm.command.WinRMCommandExecutor.execute; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueCommandExchange; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueEnumeration; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellCreation; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; +import static org.metricshub.winrm.light.FakeWsmanResponses.instance; import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mockStatic; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.metricshub.winrm.ScriptedWindowsRemoteExecutor; import org.metricshub.winrm.WindowsRemoteCommandResult; -import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMExecutorFactory; +import org.metricshub.winrm.light.FakeWsmanServer; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; -import org.mockito.MockedStatic; +/** + * End-to-end tests of {@link WinRMCommandExecutor} against {@link FakeWsmanServer}: the whole + * stack — factory, {@code LightWinRMService}, real NTLM handshake and message encryption, shell + * lifecycle, and (for the file-copy path) the in-shell file transfer — runs for real; only the + * scripted SOAP response bodies are canned. + */ class WinRMCommandExecutorTest { private static final String COMMAND = "launch"; private static final String HOSTNAME = "host"; - private static final String USERNAME = "domain\\user"; - private static final char[] PASSWORD = "pass".toCharArray(); + private static final String USERNAME = "FAKE\\user"; + private static final String USER = "user"; + private static final String DOMAIN = "FAKE"; + private static final String PASSWORD_STRING = "pass"; + private static final char[] PASSWORD = PASSWORD_STRING.toCharArray(); private static final long TIMEOUT = 30 * 1000L; private static final Path TICKET_CACHE = Paths.get("path"); private static final List AUTHENTICATIONS = singletonList(NTLM); + private static final String LOCALHOST = "127.0.0.1"; + + private static final byte[] NO_OUTPUT = new byte[0]; @TempDir Path tempDir; @@ -152,35 +183,45 @@ void testExecuteArgumentChecks() { @Test void testExecuteWithoutFilesToCopy() throws Exception { - final WindowsRemoteCommandResult expected = new WindowsRemoteCommandResult("stdout", "stderr", 1.0f, 0); + try (FakeWsmanServer server = new FakeWsmanServer(DOMAIN, USER, PASSWORD_STRING)) { + // Three executions: a null file list, an empty one, and a list of blank names must all + // take the same no-copy path. Each execution creates (and closes) its own executor. + final List> fileListVariants = new ArrayList<>(); + fileListVariants.add(null); + fileListVariants.add(emptyList()); + fileListVariants.add(singletonList(" \r\t\n ")); - final ScriptedWindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor() - .expectWql("CodeSet", List.of(Map.of("CodeSet", "65001"))) - .expectCommand(COMMAND, expected); + for (final List localFileToCopyList : fileListVariants) { + enqueueCodeSetQuery(server, "65001"); + enqueueShellCreation(server); + enqueueCommandExchange(server, "stdout".getBytes(UTF_8), "stderr".getBytes(UTF_8), 0); + enqueueShellDeletion(server); - try (final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class)) { - mockedFactory - .when(() -> WinRMExecutorFactory.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) - .thenReturn(executor); - - assertEquals( - expected, - execute(COMMAND, null, HOSTNAME, null, USERNAME, PASSWORD, null, TIMEOUT, null, null, null) - ); + final WindowsRemoteCommandResult result = execute( + COMMAND, + null, + LOCALHOST, + server.port(), + USERNAME, + PASSWORD, + null, + TIMEOUT, + localFileToCopyList, + null, + null + ); - assertEquals( - expected, - execute(COMMAND, null, HOSTNAME, null, USERNAME, PASSWORD, null, TIMEOUT, emptyList(), null, null) - ); - - // A list of blank names behaves like no list at all - assertEquals( - expected, - execute(COMMAND, null, HOSTNAME, null, USERNAME, PASSWORD, null, TIMEOUT, singletonList(" \r\t\n "), null, null) - ); + assertEquals("stdout", result.getStdout()); + assertEquals("stderr", result.getStderr()); + assertEquals(0, result.getStatusCode()); + } - assertEquals(List.of(COMMAND, COMMAND, COMMAND), executor.getExecutedCommands()); - assertTrue(executor.isClosed()); + final List requests = server.decryptedRequests(); + // Each execution queried the remote code page, ran the command verbatim (no CMD.EXE /C + // wrapper on the no-copy path), and deleted its shell on close + assertEquals(3, count(requests, "SELECT CodeSet FROM Win32_OperatingSystem")); + assertEquals(3, count(requests, "" + COMMAND + "")); + assertEquals(3, count(requests, "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete")); } } @@ -190,41 +231,34 @@ void testExecuteWithFileToCopy() throws Exception { final Path localFile = tempDir.resolve("MyScript.vbs"); Files.write(localFile, content); - final WindowsRemoteCommandResult expected = new WindowsRemoteCommandResult("stdout", "stderr", 1.0f, 0); + final String hashOutput = "SHA256 hash of file:\r\n" + + sha256Hex(content) + + "\r\nCertUtil: -hashfile command completed successfully.\r\n"; - final StringBuilder hex = new StringBuilder(); - for (final byte b : MessageDigest.getInstance("SHA-256").digest(content)) { - hex.append(String.format("%02x", b)); - } - final WindowsRemoteCommandResult remoteHash = new WindowsRemoteCommandResult( - "SHA256 hash of file:\r\n" + hex + "\r\nCertUtil: -hashfile command completed successfully.\r\n", - "", - 0.1f, - 0 - ); - final WindowsRemoteCommandResult failure = new WindowsRemoteCommandResult("", "not found", 0.1f, 1); - final WindowsRemoteCommandResult success = new WindowsRemoteCommandResult("", "", 0.1f, 0); - - final ScriptedWindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor() - .expectWql("WindowsDirectory", List.of(Map.of("WindowsDirectory", "C:\\Windows"))) - .expectWql("CodeSet", List.of(Map.of("CodeSet", "65001"))) - .expectCommand("MKDIR", success) - .expectCommand(" echo ", success) - .expectCommand("certutil -f -decode", remoteHash) - .expectCommand("MOVE /Y", remoteHash) - .expectCommand("certutil -hashfile", failure) - .expectCommand("CSCRIPT", expected); - - try (final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class)) { - mockedFactory - .when(() -> WinRMExecutorFactory.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) - .thenReturn(executor); - - final WindowsRemoteCommandResult actual = execute( + try (FakeWsmanServer server = new FakeWsmanServer(DOMAIN, USER, PASSWORD_STRING)) { + // The in-shell transfer sequence of ShellFileCopy, scripted response by response + enqueueEnumeration(server, instance("Win32_OperatingSystem", "WindowsDirectory", "C:\\Windows")); + enqueueShellCreation(server); + // 1: purge + MKDIR of the remote temporary directory + enqueueCommandExchange(server, NO_OUTPUT, NO_OUTPUT, 0); + // 2: pre-transfer digest probe — the destination does not exist yet + enqueueCommandExchange(server, NO_OUTPUT, "CertUtil: -hashfile command FAILED: 0x80070002".getBytes(UTF_8), 1); + // 3: the single chunked-echo upload leg (the payload is small) + enqueueCommandExchange(server, NO_OUTPUT, NO_OUTPUT, 0); + // 4: certutil -decode + digest probe of the staging file + enqueueCommandExchange(server, hashOutput.getBytes(UTF_8), NO_OUTPUT, 0); + // 5: publish (MOVE) + digest probe of the destination + enqueueCommandExchange(server, hashOutput.getBytes(UTF_8), NO_OUTPUT, 0); + // then the code-page query and the actual command + enqueueCodeSetQuery(server, "65001"); + enqueueCommandExchange(server, "stdout".getBytes(UTF_8), "stderr".getBytes(UTF_8), 0); + enqueueShellDeletion(server); + + final WindowsRemoteCommandResult result = execute( "CSCRIPT " + localFile, null, - HOSTNAME, - null, + LOCALHOST, + server.port(), USERNAME, PASSWORD, null, @@ -234,15 +268,44 @@ void testExecuteWithFileToCopy() throws Exception { null ); - assertEquals(expected, actual); + assertEquals("stdout", result.getStdout()); + assertEquals("stderr", result.getStderr()); + assertEquals(0, result.getStatusCode()); + + final List requests = server.decryptedRequests(); + + // The file content went over the wire base64-encoded in an echo leg + final String base64Content = Base64.getEncoder().encodeToString(content); + assertTrue(requests.stream().anyMatch(request -> request.contains(base64Content))); // The executed command references the remote copy, wrapped in CMD.EXE /C (...) - final String finalCommand = executor.getExecutedCommands().get(executor.getExecutedCommands().size() - 1); - assertTrue(finalCommand.startsWith("CMD.EXE /C (CSCRIPT ")); - assertTrue(finalCommand.contains("\\Temp\\")); + final String finalCommand = requests + .stream() + .filter(request -> request.contains("CMD.EXE /C (CSCRIPT ")) + .findFirst() + .orElseThrow(() -> new AssertionError("No CMD.EXE /C command found:\n" + String.join("\n---\n", requests))); + assertTrue(finalCommand.contains("\\Temp\\"), finalCommand); // The remote name is content-addressed: MyScript..vbs - assertTrue(finalCommand.matches("(?s).*MyScript\\.[0-9a-f]{12}\\.vbs.*")); - assertTrue(executor.isClosed()); + assertTrue(finalCommand.matches("(?s).*MyScript\\.[0-9a-f]{12}\\.vbs.*"), finalCommand); + + // close() deleted the shell + assertEquals(1, count(requests, "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete")); + } + } + + private static void enqueueCodeSetQuery(final FakeWsmanServer server, final String codeSet) { + enqueueEnumeration(server, instance("Win32_OperatingSystem", "CodeSet", codeSet)); + } + + private static long count(final List requests, final String needle) { + return requests.stream().filter(request -> request.contains(needle)).count(); + } + + private static String sha256Hex(final byte[] content) throws Exception { + final StringBuilder hex = new StringBuilder(); + for (final byte b : MessageDigest.getInstance("SHA-256").digest(content)) { + hex.append(String.format("%02x", b)); } + return hex.toString(); } } diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java new file mode 100644 index 0000000..af9da42 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java @@ -0,0 +1,300 @@ +package org.metricshub.winrm.light; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.util.Base64; + +/** + * Canned WSMan SOAP response bodies — and helpers that enqueue whole protocol exchanges — for + * end-to-end tests against {@link FakeWsmanServer}: WQL enumeration results, the command shell + * lifecycle, and WSMan faults. Shared by the protocol tests in this package and the executor and + * CLI tests that exercise the full stack from the public API down to the wire. + */ +public final class FakeWsmanResponses { + + /** Shell resource id used by the scripted shell-lifecycle responses. */ + public static final String SHELL_ID = "SHELL-1"; + + /** Command id used by the scripted command exchanges. */ + public static final String COMMAND_ID = "CMD-1"; + + private static final String SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"; + private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; + private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; + private static final String RSP = "http://schemas.microsoft.com/wbem/wsman/1/windows/shell"; + private static final String FAULT_NS = "http://schemas.microsoft.com/wbem/wsman/1/wsmanfault"; + private static final String WMI_NS_PREFIX = "http://schemas.microsoft.com/wbem/wsman/1/wmi/root/cimv2/"; + + private FakeWsmanResponses() {} + + // --- SOAP body builders -------------------------------------------------- + + /** + * Wrap a body in a SOAP 1.2 envelope. + * + * @param body the body content + * @return the complete envelope + */ + public static String envelope(final String body) { + return "" + body + ""; + } + + /** + * One WMI instance as WinRM serializes it in an enumeration, e.g. + * {@code instance("Win32_Service", "Name", "Spooler", "State", "Running")}. + * + * @param className the WMI class name + * @param properties alternating property names and values + * @return the instance element + */ + public static String instance(final String className, final String... properties) { + final StringBuilder xml = new StringBuilder(); + xml.append(""); + for (int i = 0; i + 1 < properties.length; i += 2) { + xml + .append("') + .append(properties[i + 1]) + .append("'); + } + return xml.append("').toString(); + } + + /** + * A complete single-page WQL result: an optimized EnumerateResponse carrying the items and the + * end-of-sequence marker, so no Pull follows. + * + * @param instances the serialized WMI instances (see {@link #instance(String, String...)}) + * @return the EnumerateResponse body + */ + public static String enumerationDone(final String... instances) { + final StringBuilder xml = new StringBuilder(); + xml + .append(""); + for (final String item : instances) { + xml.append(item); + } + return xml.append("").toString(); + } + + /** + * The ResourceCreated body answering a shell Create request. + * + * @param shellId the shell id the response designates + * @return the ResourceCreated body + */ + public static String resourceCreated(final String shellId) { + return ("" + + "http://127.0.0.1/wsman" + + "" + + "" + + RSP + + "/cmd" + + "" + + shellId + + "" + + ""); + } + + /** + * The CommandResponse body answering a Command request. + * + * @param commandId the command id the response designates + * @return the CommandResponse body + */ + public static String commandResponse(final String commandId) { + return ("" + + commandId + + ""); + } + + /** + * A ReceiveResponse body carrying the given streams and, optionally, the final command state. + * + * @param streams the concatenated Stream elements (see {@link #stream(String, String, byte[])}) + * @param commandState the CommandState element (see {@link #done(String, int)}), or null while + * the command is still running + * @return the ReceiveResponse body + */ + public static String receiveResponse(final String streams, final String commandState) { + return ("" + + streams + + (commandState == null ? "" : commandState) + + ""); + } + + /** + * One base64-encoded output Stream element of a ReceiveResponse. + * + * @param name the stream name (stdout or stderr) + * @param commandId the command the stream belongs to + * @param content the raw stream bytes + * @return the Stream element + */ + public static String stream(final String name, final String commandId, final byte[] content) { + return ("" + + Base64.getEncoder().encodeToString(content) + + ""); + } + + /** + * The CommandState element reporting command completion. + * + * @param commandId the finished command + * @param exitCode the command exit code + * @return the CommandState element + */ + public static String done(final String commandId, final int exitCode) { + return ("" + + exitCode + + ""); + } + + /** + * The SignalResponse body answering the terminate Signal. + * + * @return the SignalResponse body + */ + public static String signalResponse() { + return ""; + } + + /** + * A complete WSMan fault envelope (not to be wrapped in {@link #envelope(String)}). + * + * @param code the WSManFault code + * @param reason the fault reason text + * @return the fault envelope + */ + public static String fault(final String code, final String reason) { + return fault(code, reason, null); + } + + /** + * A complete WSMan fault envelope (not to be wrapped in {@link #envelope(String)}) with a + * provider-level detail message. + * + * @param code the WSManFault code + * @param reason the fault reason text + * @param detailMessage the provider-level detail, or null to repeat the reason + * @return the fault envelope + */ + public static String fault(final String code, final String reason, final String detailMessage) { + return ("" + + "s:Receiver" + + "" + + reason + + "" + + "" + + "" + + (detailMessage == null ? reason : detailMessage) + + "" + + ""); + } + + // --- whole-exchange enqueue helpers --------------------------------------- + + /** + * Enqueue a complete single-page WQL result: the Enumerate request is answered with the items + * and the end-of-sequence marker, so the client issues no Pull. + * + * @param server the fake server to script + * @param instances the serialized WMI instances (see {@link #instance(String, String...)}) + */ + public static void enqueueEnumeration(final FakeWsmanServer server, final String... instances) { + server.enqueue(200, envelope(enumerationDone(instances))); + } + + /** + * Enqueue the shell-creation response that answers the Create request preceding the first + * command executed on a fresh executor. + * + * @param server the fake server to script + */ + public static void enqueueShellCreation(final FakeWsmanServer server) { + server.enqueue(200, envelope(resourceCreated(SHELL_ID))); + } + + /** + * Enqueue a complete command exchange: the CommandResponse, one ReceiveResponse carrying the + * whole output and the final state, and the SignalResponse to the terminate Signal. + * + * @param server the fake server to script + * @param stdout the raw stdout bytes (as the remote code page encodes them) + * @param stderr the raw stderr bytes + * @param exitCode the command exit code + */ + public static void enqueueCommandExchange( + final FakeWsmanServer server, + final byte[] stdout, + final byte[] stderr, + final int exitCode + ) { + final StringBuilder streams = new StringBuilder(); + if (stdout.length > 0) { + streams.append(stream("stdout", COMMAND_ID, stdout)); + } + if (stderr.length > 0) { + streams.append(stream("stderr", COMMAND_ID, stderr)); + } + server + .enqueue(200, envelope(commandResponse(COMMAND_ID))) + .enqueue(200, envelope(receiveResponse(streams.toString(), done(COMMAND_ID, exitCode)))) + .enqueue(200, envelope(signalResponse())); + } + + /** + * Enqueue the response to the shell Delete that {@code close()} sends once a shell was created. + * + * @param server the fake server to script + */ + public static void enqueueShellDeletion(final FakeWsmanServer server) { + server.enqueue(200, envelope("")); + } +} diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java index 16fb878..1274719 100644 --- a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java @@ -1,5 +1,25 @@ package org.metricshub.winrm.light; +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -31,7 +51,7 @@ * The NTLMv2 verification is real: a client that derives a wrong hash (e.g. a domain-case * regression) fails authentication here just like against a real host. */ -final class FakeWsmanServer implements AutoCloseable { +public final class FakeWsmanServer implements AutoCloseable { /** One scripted HTTP response: status code and the plaintext SOAP body to encrypt and serve. */ static final class Scripted { @@ -83,7 +103,15 @@ static final class Scripted { private volatile boolean closed; private volatile boolean chunkedResponses; - FakeWsmanServer(final String domain, final String user, final String password) throws IOException { + /** + * Start the fake server on an ephemeral local port. + * + * @param domain the NetBIOS domain the client is expected to authenticate with + * @param user the user name the client is expected to authenticate with + * @param password the password the client's NTLMv2 proof is verified against + * @throws IOException when the listening socket cannot be opened + */ + public FakeWsmanServer(final String domain, final String user, final String password) throws IOException { this.expectedDomain = domain.toUpperCase(Locale.ROOT); this.expectedUser = user; this.expectedPassword = password; @@ -93,12 +121,21 @@ static final class Scripted { this.acceptThread.start(); } - int port() { + /** + * @return the local port the server listens on + */ + public int port() { return serverSocket.getLocalPort(); } - /** Queue the next scripted response (served in order, one per decrypted request). */ - FakeWsmanServer enqueue(final int status, final String soapBody) { + /** + * Queue the next scripted response (served in order, one per decrypted request). + * + * @param status the HTTP status code to respond with + * @param soapBody the plaintext SOAP body to encrypt and serve + * @return this server, for chaining + */ + public FakeWsmanServer enqueue(final int status, final String soapBody) { synchronized (script) { script.addLast(new Scripted(status, soapBody)); } @@ -110,14 +147,20 @@ FakeWsmanServer enqueue(final int status, final String soapBody) { * extension, and trailer fields after the terminating chunk — instead of {@code Content-Length}, * like a real WinRM host does. A client that mis-reads the framing (e.g. leaves the trailers in * the socket) desyncs the kept-alive connection and fails on the NEXT request. + * + * @return this server, for chaining */ - FakeWsmanServer withChunkedResponses() { + public FakeWsmanServer withChunkedResponses() { chunkedResponses = true; return this; } - /** The plaintext SOAP request bodies received so far, in order (after decryption). */ - List decryptedRequests() { + /** + * The plaintext SOAP request bodies received so far, in order (after decryption). + * + * @return a copy of the decrypted request bodies + */ + public List decryptedRequests() { return new ArrayList<>(decryptedRequests); } diff --git a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java index 555edf2..f7c62fe 100644 --- a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java +++ b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java @@ -1,11 +1,39 @@ package org.metricshub.winrm.light; +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + 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.metricshub.winrm.light.FakeWsmanResponses.commandResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.done; +import static org.metricshub.winrm.light.FakeWsmanResponses.envelope; +import static org.metricshub.winrm.light.FakeWsmanResponses.fault; +import static org.metricshub.winrm.light.FakeWsmanResponses.instance; +import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated; +import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.stream; import java.nio.charset.StandardCharsets; -import java.util.Base64; import java.util.List; import java.util.Map; import org.junit.jupiter.api.AfterEach; @@ -29,11 +57,9 @@ class WsmanProtocolTest { private static final String PASSWORD = "s3cret-Passw0rd"; private static final long TIMEOUT = 30_000L; - private static final String SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"; private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; private static final String RSP = "http://schemas.microsoft.com/wbem/wsman/1/windows/shell"; - private static final String FAULT_NS = "http://schemas.microsoft.com/wbem/wsman/1/wsmanfault"; private FakeWsmanServer server; @@ -197,18 +223,17 @@ void commandLifecycleReassemblesMultibyteOutputSplitAcrossReceives() throws Exce server .enqueue(200, envelope(resourceCreated("SHELL-1"))) .enqueue(200, envelope(commandResponse("CMD-1"))) - .enqueue(200, envelope(receiveResponse("CMD-1", stream("stdout", chunk1), null))) + .enqueue(200, envelope(receiveResponse(stream("stdout", "CMD-1", chunk1), null))) .enqueue( 200, envelope( receiveResponse( - "CMD-1", - stream("stdout", chunk2) + stream("stderr", "warn!".getBytes(StandardCharsets.UTF_8)), + stream("stdout", "CMD-1", chunk2) + stream("stderr", "CMD-1", "warn!".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 7) ) ) ) - .enqueue(200, envelope("")); + .enqueue(200, envelope(signalResponse())); try (LightWinRMService service = client(PASSWORD)) { final WindowsRemoteCommandResult result = service.executeCommand( @@ -255,9 +280,11 @@ void receiveRetriesOnOperationTimeoutFault() throws Exception { ) .enqueue( 200, - envelope(receiveResponse("CMD-1", stream("stdout", "late".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + envelope( + receiveResponse(stream("stdout", "CMD-1", "late".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0)) + ) ) - .enqueue(200, envelope("")); + .enqueue(200, envelope(signalResponse())); try (LightWinRMService service = client(PASSWORD)) { final WindowsRemoteCommandResult result = service.executeCommand("slow", null, StandardCharsets.UTF_8, TIMEOUT); @@ -282,15 +309,18 @@ void commandExitCodeAboveIntegerMaxIsNarrowedNotRejected() throws Exception { 200, envelope( receiveResponse( - "CMD-1", - stream("stdout", "CertUtil: -hashfile command FAILED: 0x80070002".getBytes(StandardCharsets.UTF_8)), + stream( + "stdout", + "CMD-1", + "CertUtil: -hashfile command FAILED: 0x80070002".getBytes(StandardCharsets.UTF_8) + ), "2147942402" ) ) ) - .enqueue(200, envelope("")); + .enqueue(200, envelope(signalResponse())); try (LightWinRMService service = client(PASSWORD)) { final WindowsRemoteCommandResult result = service.executeCommand( @@ -314,7 +344,7 @@ void terminateSignalToleratesShellNotFoundFault() throws Exception { .enqueue(200, envelope(commandResponse("CMD-1"))) .enqueue( 200, - envelope(receiveResponse("CMD-1", stream("stdout", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + envelope(receiveResponse(stream("stdout", "CMD-1", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) ) .enqueue( 500, @@ -372,90 +402,7 @@ void wrongPasswordSurfacesTheCxfAuthenticationErrorMessage() throws Exception { // --- response body builders ----------------------------------------------------- - private static String envelope(final String body) { - return "" + body + ""; - } - private static String service(final String name, final String state) { - return ("" + - "" + - name + - "" + - state + - ""); - } - - private static String resourceCreated(final String shellId) { - return ("" + - "http://127.0.0.1/wsman" + - "" + - "" + - RSP + - "/cmd" + - "" + - shellId + - "" + - ""); - } - - private static String commandResponse(final String commandId) { - return ("" + - commandId + - ""); - } - - private static String receiveResponse(final String commandId, final String streams, final String commandState) { - return ("" + - streams + - (commandState == null ? "" : commandState) + - ""); - } - - private static String stream(final String name, final byte[] content) { - return ("" + - Base64.getEncoder().encodeToString(content) + - ""); - } - - private static String done(final String commandId, final int exitCode) { - return ("" + - exitCode + - ""); - } - - private static String fault(final String code, final String reason) { - return fault(code, reason, null); - } - - private static String fault(final String code, final String reason, final String detailMessage) { - return ("" + - "s:Receiver" + - "" + - reason + - "" + - "" + - "" + - (detailMessage == null ? reason : detailMessage) + - "" + - ""); + return instance("Win32_Service", "Name", name, "State", state); } } diff --git a/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java b/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java index ce8393f..6a71f69 100644 --- a/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java +++ b/src/test/java/org/metricshub/winrm/wql/WinRMWqlExecutorTest.java @@ -1,32 +1,43 @@ package org.metricshub.winrm.wql; +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + import static java.nio.file.Paths.get; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; 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.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueEnumeration; +import static org.metricshub.winrm.light.FakeWsmanResponses.instance; import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; import static org.metricshub.winrm.wql.WinRMWqlExecutor.executeWql; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; import java.nio.file.Path; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; -import org.metricshub.winrm.WindowsRemoteExecutor; -import org.metricshub.winrm.service.WinRMEndpoint; -import org.metricshub.winrm.service.WinRMExecutorFactory; +import org.metricshub.winrm.light.FakeWsmanServer; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; -import org.mockito.MockedStatic; class WinRMWqlExecutorTest { @@ -53,7 +64,7 @@ void resultCollectionsAreDefensivelyCopiedAndUnmodifiable() { } @Test - void testExecute() throws Exception { + void testExecuteArgumentChecks() { final String wqlQuery = "Select Name,Path from Win32_Share"; final String hostname = "host"; final String username = "user"; @@ -62,7 +73,6 @@ void testExecute() throws Exception { final Path ticketCache = get("path"); final List authentications = singletonList(NTLM); - // check arguments assertThrows( IllegalArgumentException.class, () -> executeWql(HTTPS, null, 5986, username, password, null, wqlQuery, timeout, ticketCache, authentications) @@ -92,51 +102,46 @@ void testExecute() throws Exception { IllegalArgumentException.class, () -> executeWql(HTTPS, hostname, 5986, username, password, null, wqlQuery, 0L, ticketCache, authentications) ); + } + + @Test + void executesTheQueryThroughTheRealProtocolStack() throws Exception { + final String wqlQuery = "Select Name,Path from Win32_Share"; - try (final MockedStatic mockedFactory = mockStatic(WinRMExecutorFactory.class)) { - final WindowsRemoteExecutor executor = mock(WindowsRemoteExecutor.class); - - final List> result = new ArrayList<>(); - { - final Map row = new HashMap<>(); - row.put("Name", "C$"); - row.put("Path", "C:\\"); - result.add(row); - } - { - final Map row = new HashMap<>(); - row.put("Name", "SEN_ShareFor_PC$"); - row.put("Path", "C:\\Windows\\Temp\\SEN_ShareFor_PC$"); - result.add(row); - } - - mockedFactory - .when(() -> WinRMExecutorFactory.createInstance(any(WinRMEndpoint.class), anyLong(), isNull(), isNull())) - .thenReturn(executor); - - doReturn(result).when(executor).executeWql(eq(wqlQuery), anyLong()); + // End to end: executeWql -> WinRMExecutorFactory -> LightWinRMService -> WsmanClient -> + // real NTLM handshake and message encryption against the in-process WSMan server. + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "s3cret-Passw0rd")) { + enqueueEnumeration( + server, + instance("Win32_Share", "Name", "C$", "Path", "C:\\"), + instance("Win32_Share", "Name", "SEN_ShareFor_PC$", "Path", "C:\\Windows\\Temp\\SEN_ShareFor_PC$") + ); final WinRMWqlExecutor actual = executeWql( null, - hostname, - null, - username, - password, + "127.0.0.1", + server.port(), + "FAKE\\user", + "s3cret-Passw0rd".toCharArray(), null, wqlQuery, - timeout, + 30 * 1000L, null, null ); + // Headers keep the order of the WQL SELECT clause, and each row is mapped onto it assertEquals(asList("Name", "Path"), actual.getHeaders()); - - final List> rows = asList( - asList("C$", "C:\\"), - asList("SEN_ShareFor_PC$", "C:\\Windows\\Temp\\SEN_ShareFor_PC$") + assertEquals( + asList(asList("C$", "C:\\"), asList("SEN_ShareFor_PC$", "C:\\Windows\\Temp\\SEN_ShareFor_PC$")), + actual.getRows() ); + assertTrue(actual.getExecutionTime() >= 0); - assertEquals(rows, actual.getRows()); + // The query the factory-created executor put on the wire is the caller's, verbatim + final List requests = server.decryptedRequests(); + assertEquals(1, requests.size(), () -> String.join("\n---\n", requests)); + assertTrue(requests.get(0).contains(wqlQuery), requests.get(0)); } } }