diff --git a/README.md b/README.md index a81f796..a3047fd 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ try (WinRMClient client = WinRMClient.builder("server01.acme.com") CommandResult result = client.command("ipconfig /all").execute(); System.out.println(result.stdout()); + // PowerShell — delivered encoded (-EncodedCommand): no quoting or escaping needed + CommandResult ps = client.powerShell("Get-Service | Where-Object Status -eq 'Running'").execute(); + // Copy a file to the host (through the WinRM channel itself — no SMB) client.uploadFile(Path.of("collect.ps1"), "C:\\Windows\\Temp\\collect.ps1"); } diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index fb3a820..a26ff5c 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -24,10 +24,12 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -50,8 +52,54 @@ public final class CommandRequest { /** How many bytes of pre-supplied input are read and sent at a time. */ private static final int STDIN_BUFFER_SIZE = 64 * 1024; + /** The invocation an encoded PowerShell script is appended to. */ + private static final String POWERSHELL_PREFIX = "powershell.exe -NoProfile -NonInteractive -EncodedCommand "; + + /** + * The invocation running a PowerShell script transferred as a file — the automatic fallback + * for scripts too long to ride the command line encoded ({@code %s} is the path of the remote + * copy). The file's content is run as a dot-sourced script block rather than + * executed with {@code -File}, keeping the script behaving like the encoded form: it stays + * pathless ({@code $PSScriptRoot} and {@code $MyInvocation.MyCommand.Path} are empty in both + * forms), a top-level {@code param(...)} block keeps working, dot-sourcing runs the top level + * in the session scope exactly like {@code -EncodedCommand} does, and the execution policy — + * which only governs script files — never applies. The one remaining observable difference is + * {@code $MyInvocation}'s own metadata ({@code InvocationName}, {@code Line}), which reflects + * this wrapper invocation for a transferred script. The file is read with a BOM-aware .NET + * method rather than {@code Get-Content -Raw}, which PowerShell 2.0 (Windows Server 2008 R2) + * does not have. + */ + private static final String POWERSHELL_FILE_INVOCATION = "powershell.exe -NoProfile -NonInteractive -Command " + + "\". ([ScriptBlock]::Create([System.IO.File]::ReadAllText('%s')))\""; + + /** + * Local name of the fallback script file. The name is constant: the file is created in a + * fresh temporary directory (no collisions), and the transfer content-addresses the remote + * copy ({@code winrm-powershell..ps1}) — so re-running an identical script finds the + * remote copy already present and skips the transfer. + */ + private static final String POWERSHELL_FILE_NAME = "winrm-powershell.ps1"; + + /** + * The UTF-8 byte order mark, prepended to the fallback script file: without it, Windows + * PowerShell reads the file with the ANSI code page and mangles every non-ASCII character. + */ + private static final byte[] UTF8_BOM = { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF }; + + /** cmd.exe rejects command lines longer than 8191 characters. */ + private static final int MAX_COMMAND_LINE_LENGTH = 8191; + + /** + * The {@code CMD.EXE /C (...)} wrapper an uploaded request adds around the invocation. The + * encoded-form length check always reserves room for it, so the encode-or-file decision does + * not depend on whether {@link #upload(Path...)} is called. + */ + private static final int CMD_WRAPPER_LENGTH = "CMD.EXE /C ()".length(); + private final WinRMClient client; + /** The command line — or, for a {@link WinRMClient#powerShell(String)} request, the raw script text. */ private final String commandLine; + private final boolean powerShell; private String workingDirectory; private final Map environment = new LinkedHashMap<>(); private Duration timeout; @@ -76,12 +124,72 @@ private interface StdinSource { * @param commandLine the command line to execute */ CommandRequest(final WinRMClient client, final String commandLine) { + this(client, commandLine, false); + } + + /** + * Create the request. + * + * @param client the client the command runs on + * @param commandLine the command line to execute — or, when {@code powerShell} is set, the raw + * script text, turned into a {@code powershell.exe} invocation when the command starts + * (after any {@link #upload(Path...)} path rewriting) + * @param powerShell whether the text is a PowerShell script + */ + CommandRequest(final WinRMClient client, final String commandLine, final boolean powerShell) { Utils.checkNonBlank(commandLine, "commandLine"); this.client = client; this.commandLine = commandLine; + this.powerShell = powerShell; this.timeout = client.defaultTimeout(); } + /** + * Build the {@code powershell.exe -EncodedCommand} invocation delivering the given script + * verbatim (base64 of its UTF-16LE bytes, the encoding {@code -EncodedCommand} expects) — or + * report that the script is too long to ride the command line and must travel as a file. + * + * @param script the PowerShell script + * @return the invocation command line, or {@code null} when it would exceed the remote + * shell's command-line limit (room for the {@code CMD.EXE /C} wrapper included, so + * the decision does not depend on {@link #upload(Path...)}) + */ + private static String encodePowerShell(final String script) { + final String encoded = POWERSHELL_PREFIX + + Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE)); + return encoded.length() > MAX_COMMAND_LINE_LENGTH - CMD_WRAPPER_LENGTH ? null : encoded; + } + + /** + * Run the fallback path for a script too long to ride the command line: write it (UTF-8 with + * a BOM) to a local temporary file, transfer that file to the remote host exactly like an + * {@link #upload(Path...)}, and return the invocation running the remote copy's content as a + * script block. The local file is deleted before returning; the remote copy is + * content-addressed, so re-running an identical script skips the transfer. + */ + private String powerShellFromFile(final String script, final long timeoutMillis, final long start) + throws IOException, TimeoutException, WindowsRemoteException { + final Path directory = Files.createTempDirectory("winrm-ps"); + final Path file = directory.resolve(POWERSHELL_FILE_NAME); + try { + final byte[] body = script.getBytes(StandardCharsets.UTF_8); + final byte[] content = new byte[UTF8_BOM.length + body.length]; + System.arraycopy(UTF8_BOM, 0, content, 0, UTF8_BOM.length); + System.arraycopy(body, 0, content, UTF8_BOM.length, body.length); + Files.write(file, content); + return ShellFileCopy.copyLocalFilesToRemote( + client.executor(), + String.format(POWERSHELL_FILE_INVOCATION, file), + List.of(file.toString()), + environment, + TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to transfer the PowerShell script") + ); + } finally { + Files.deleteIfExists(file); + Files.deleteIfExists(directory); + } + } + /** * Set the working directory of the remote process. The remote command shell is created on * the first command a client executes and is reused afterward, so this setting takes effect @@ -497,22 +605,41 @@ private Prepared prepare(final long timeoutMillis, final long start) String actualCommand = commandLine; String actualWorkingDirectory = workingDirectory; Map actualEnvironment = environment; + boolean transfersCreatedTheShell = false; if (!uploads.isEmpty()) { // Copy the files through the command shell and rewrite the command to reference the // remote copies. The transfer commands are what actually creates the shell, so the // shell-scoped environment must ride them — the real command then inherits it. The // working directory is not carried over: with uploads it has never applied, and the - // transfer commands were built for the default directory. + // transfer commands were built for the default directory. A PowerShell script is + // rewritten here as plain text, BEFORE it is encoded below. final List localFiles = uploads.stream().map(Path::toString).collect(Collectors.toList()); - final String updatedCommand = ShellFileCopy.copyLocalFilesToRemote( + actualCommand = ShellFileCopy.copyLocalFilesToRemote( client.executor(), commandLine, localFiles, environment, TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to copy the local files") ); - actualCommand = String.format("CMD.EXE /C (%s)", updatedCommand); + transfersCreatedTheShell = true; + } + + if (powerShell) { + // actualCommand holds the (possibly rewritten) script text. A script short enough + // rides the command line encoded; a longer one travels as a temporary file and runs + // with -File — transparently, the caller never sees the command-line length limit. + final String encoded = encodePowerShell(actualCommand); + if (encoded != null) { + actualCommand = encoded; + } else { + actualCommand = powerShellFromFile(actualCommand, timeoutMillis, start); + transfersCreatedTheShell = true; + } + } + + if (transfersCreatedTheShell) { + actualCommand = String.format("CMD.EXE /C (%s)", actualCommand); actualWorkingDirectory = null; // Already applied when the transfer commands created the shell. actualEnvironment = null; diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index 87b0ac2..3d2e88f 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -126,6 +126,50 @@ public CommandRequest command(final String commandLine) { return new CommandRequest(this, commandLine); } + /** + * Prepare a PowerShell script execution. Nothing is sent until + * {@link CommandRequest#execute()} is called. + *

+ * The script travels base64-encoded ({@code powershell.exe -NoProfile -NonInteractive + * -EncodedCommand ...}), so it needs no quoting or escaping whatsoever: quotes, pipes, + * newlines, and {@code $variables} reach PowerShell exactly as written. + * + *

{@code
+	 * CommandResult result = client.powerShell(
+	 * 	"Get-Service | Where-Object { $_.Status -eq 'Running' } | Select-Object -First 5 Name"
+	 * ).execute();
+	 * }
+ * + * The returned request is the same as for {@link #command(String)}: every option and both + * terminals apply unchanged — including {@link CommandRequest#upload(Path...)}, whose path + * rewriting happens on the script text before it is encoded, so a script referencing an + * uploaded file runs against the remote copy. {@code powershell.exe} exits with 0 on success + * and 1 when the script ends with a terminating error; call {@code exit } in the script for + * a specific exit code. + *

+ * There is no practical script size limit. A script whose encoded invocation would not fit + * the remote shell's command line (roughly 3000 characters of script) is automatically + * transferred as a temporary {@code .ps1} file — through the WinRM connection itself, exactly + * like {@link CommandRequest#upload(Path...)} — and its content run as a dot-sourced script + * block, which keeps the script behaving like the encoded form: pathless + * ({@code $PSScriptRoot} and {@code $MyInvocation.MyCommand.Path} stay empty either way), + * top-level scope and {@code param(...)} intact, and out of reach of the host's execution + * policy (which only governs script files). Only {@code $MyInvocation}'s own metadata + * ({@code InvocationName}, {@code Line}) reflects the wrapper invocation for a transferred + * script. The remote copy is content-addressed, so re-running an identical script skips the + * transfer. Like any request with uploads, the transfer commands are then what creates the + * remote shell, so the shell-scoped {@link CommandRequest#workingDirectory(String)} does not + * apply. + * + * @param script the PowerShell script to execute, verbatim + * @return the request, to configure and execute + * @throws IllegalArgumentException when the script is blank + */ + public CommandRequest powerShell(final String script) { + Utils.checkNonBlank(script, "script"); + return new CommandRequest(this, script, true); + } + /** * Copy a local file to an explicit path on the remote host, through the WinRM connection * itself (no SMB, no extra port). The transfer is digest-verified and skipped when the diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 63536e9..71060a4 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -1,4 +1,4 @@ -keywords: command, execute, cmd, stdout, stderr, stdin, exit code, file copy, script +keywords: command, execute, cmd, powershell, encodedcommand, stdout, stderr, stdin, exit code, file copy, script description: Execute remote commands with the fluent WinRMClient API, capture output and exit codes, feed standard input, and copy local files to the host. # Remote Commands @@ -53,6 +53,45 @@ Everything between `command(...)` and `execute()` is optional: | `stdinCharset(Charset)` | the output charset | The charset used to *encode* standard input, when it differs from the output charset (see below). | | `onStdout(Consumer)` / `onStderr(Consumer)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). | +## Running PowerShell + +`powerShell(...)` prepares a PowerShell script execution the same way `command(...)` prepares a +command line. The script travels base64-encoded +(`powershell.exe -NoProfile -NonInteractive -EncodedCommand …`), so **no quoting or escaping is +ever needed**: quotes, pipes, newlines, and `$variables` reach PowerShell exactly as written. + +```java +CommandResult result = client.powerShell( + "Get-Service | Where-Object { $_.Status -eq 'Running' } | Select-Object -First 5 Name" + ).execute(); +``` + +It returns the same request object as `command(...)`: every option and terminal described on this +page — `timeout(...)`, `charset(...)`, `stdin(...)`, `onStdout(...)`, `execute()`, `start()` — +works unchanged. + +Points to know: + +* **Exit code** — `powershell.exe` exits with 0 on success and 1 when the script ends with a + terminating error; call `exit ` in the script for a specific code. +* **Uploads** — `upload(...)` works as with any command: references to the uploaded files in the + script are rewritten to the remote copies *before* the script is encoded. +* **Script size** — there is none to worry about. A script short enough rides the command line + encoded; a longer one (roughly 3000 characters and up, where the encoded invocation would no + longer fit the remote shell's 8191-character command line) is automatically transferred as a + temporary `.ps1` file — through the WinRM connection itself, exactly like `upload(...)` — and + its **content** run as a dot-sourced script block (`[ScriptBlock]::Create`), which keeps the + script behaving like the encoded form: pathless (`$PSScriptRoot` and + `$MyInvocation.MyCommand.Path` stay empty either way), top-level scope and `param(...)` intact, + and out of reach of the host's execution policy (which only governs script files). The one + remaining observable difference is `$MyInvocation`'s own metadata (`InvocationName`, `Line`), + which reflects the wrapper invocation for a transferred script. The remote copy is + [content-addressed](file-transfers.html), so re-running an identical script skips the transfer; + and like any request with uploads, the transfer commands are then what creates the remote + shell, so the shell-scoped `workingDirectory(...)` does not apply. +* **Windows PowerShell** — the script runs in `powershell.exe` (Windows PowerShell 5.x, present on + every supported Windows). To target PowerShell 7+, invoke `pwsh` yourself with `command(...)`. + ## The result [`CommandResult`](apidocs/org/metricshub/winrm/CommandResult.html) is an immutable value: diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 3c51226..ec3e928 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -12,8 +12,9 @@ The **WinRM Java Client** is a small library that talks to the Windows Remote Ma * run **WQL / WMI queries** such as `SELECT Name, State FROM Win32_Service` and read the rows back ([WQL Queries](wql.html)), and -* **execute remote commands**, capturing standard output, standard error and the exit code — - optionally copying local script files to the host first ([Remote Commands](commands.html)). +* **execute remote commands** — `cmd.exe` command lines or PowerShell scripts — capturing standard + output, standard error and the exit code, optionally copying local script files to the host + first ([Remote Commands](commands.html)). Both operations can also **stream**: WQL rows are consumed page by page as they arrive (`stream()`), and command output is consumed while the command is still running (`start()`, @@ -103,6 +104,13 @@ CommandResult result = client.command("ipconfig /all").execute(); System.out.println(result.stdout()); ``` +And so do PowerShell scripts — delivered base64-encoded (`-EncodedCommand`), so they need no +quoting or escaping at all: + +```java +CommandResult ps = client.powerShell("Get-Service | Where-Object Status -eq 'Running'").execute(); +``` + Failures are reported through the unchecked [`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html) hierarchy. The static one-shot helpers that predate `WinRMClient` diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index b65038d..c1d2e3f 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -39,7 +39,10 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Base64; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -281,6 +284,211 @@ void environmentVariablesAreSentInTheCreateShellRequest() throws Exception { assertTrue(environment < workingDirectory && workingDirectory < inputStreams, create); } + @Test + void powerShellEncodesTheScriptSoNoEscapingIsNeeded() throws Exception { + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", "CMD-1", "Spooler".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0)) + ) + ) + .enqueue(200, envelope(signalResponse())); + + // Quotes, pipes, a $variable, a newline, and non-ASCII text: none of it needs escaping. + final String script = "Get-Service | Where-Object { $_.Status -eq 'Running' } |\n" + + "ForEach-Object { \"état: $($_.Name)\" }"; + + try (WinRMClient client = builder(PASSWORD).build()) { + final CommandResult result = client.powerShell(script).execute(); + assertEquals("Spooler", result.stdout()); + assertEquals(0, result.exitCode()); + } + + final String command = server.decryptedRequests().get(1); + final Matcher matcher = Pattern + .compile("powershell\\.exe -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/=]+)") + .matcher(command); + assertTrue(matcher.find(), command); + // What powershell.exe decodes on the host is exactly the script, UTF-16LE as -EncodedCommand expects. + assertEquals(script, new String(Base64.getDecoder().decode(matcher.group(1)), StandardCharsets.UTF_16LE)); + } + + @Test + void powerShellUploadRewritesThePathInsideTheScriptBeforeEncodingIt(@TempDir final java.nio.file.Path tempDir) + throws Exception { + // The script references a LOCAL file handed to upload(...): the path must be rewritten to + // the remote copy while the script is still plain text — an already-encoded script would + // keep the client-side path and fail on the host. + final byte[] content = "Write-Output 'collect'".getBytes(StandardCharsets.UTF_8); + final java.nio.file.Path localFile = tempDir.resolve("collect.ps1"); + java.nio.file.Files.write(localFile, content); + final StringBuilder digest = new StringBuilder(); + for (final byte b : java.security.MessageDigest.getInstance("SHA-256").digest(content)) { + digest.append(String.format("%02x", b)); + } + final String certutil = "SHA256 hash of file x:\r\n" + + digest + + "\r\nCertUtil: -hashfile command completed successfully.\r\n"; + + server + // ShellFileCopy locates the Windows directory with a WQL query (no shell involved)... + .enqueue(200, envelope(enumerationDone(instance("Win32_OperatingSystem", "WindowsDirectory", "C:\\Windows")))) + // ...then its first command leg (cleanup + MKDIR) creates the shell... + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue(200, envelope(receiveResponse("", done("CMD-1", 0)))) + .enqueue(200, envelope(signalResponse())) + // ...the digest probe reports an identical remote copy (transfer skipped)... + .enqueue(200, envelope(commandResponse("CMD-2"))) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", "CMD-2", certutil.getBytes(StandardCharsets.UTF_8)), done("CMD-2", 0)) + ) + ) + .enqueue(200, envelope(signalResponse())) + // ...and the encoded PowerShell invocation runs in the SAME shell. + .enqueue(200, envelope(commandResponse("CMD-3"))) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", "CMD-3", "collect".getBytes(StandardCharsets.UTF_8)), done("CMD-3", 0)) + ) + ) + .enqueue(200, envelope(signalResponse())); + + final String script = "& '" + localFile + "' -Verbose"; + try (WinRMClient client = builder(PASSWORD).build()) { + final CommandResult result = client.powerShell(script).upload(localFile).execute(); + assertEquals("collect", result.stdout()); + } + + final String command = server + .decryptedRequests() + .stream() + .filter(r -> r.contains("-EncodedCommand")) + .findFirst() + .orElseThrow(() -> new AssertionError(String.join("\n---\n", server.decryptedRequests()))); + final Matcher matcher = Pattern.compile("-EncodedCommand ([A-Za-z0-9+/=]+)").matcher(command); + assertTrue(matcher.find(), command); + final String decoded = new String(Base64.getDecoder().decode(matcher.group(1)), StandardCharsets.UTF_16LE); + // The decoded script calls the content-addressed remote copy, not the local file. + assertTrue(decoded.contains("collect." + digest.substring(0, 12) + ".ps1"), decoded); + assertFalse(decoded.contains(localFile.toString()), decoded); + assertTrue(decoded.endsWith("' -Verbose"), decoded); + } + + @Test + void powerShellRejectsABlankScript() throws Exception { + try (WinRMClient client = builder(PASSWORD).build()) { + assertThrows(IllegalArgumentException.class, () -> client.powerShell(null)); + assertThrows(IllegalArgumentException.class, () -> client.powerShell(" \n\t")); + } + } + + @Test + void powerShellKeepsAScriptJustUnderTheLimitEncoded() throws Exception { + // 3040 characters encode to an 8166-character invocation, which still fits the remote + // shell's 8191-character command line WITH room for the CMD.EXE /C ( ) wrapper an uploaded + // request would add — so it rides the command line, no file transfer involved. + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", "CMD-1", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + assertEquals("ok", client.powerShell("x".repeat(3040)).execute().stdout()); + } + + final String command = server.decryptedRequests().get(1); + assertTrue(command.contains("-EncodedCommand"), command); + } + + @Test + void powerShellRunsALongScriptFromATransferredFile() throws Exception { + // 4000 characters encode to a ~10700-character invocation, beyond cmd.exe's 8191 limit: + // the script must travel as a temporary .ps1 file — transferred through the WinRM channel + // like an upload — whose content is then run as a script block. The caller never sees any + // of this. + final String script = "Write-Output '" + "x".repeat(4000) + "'"; + + // The transferred file is the script encoded as UTF-8 with a BOM (Windows PowerShell + // would read a BOM-less file with the ANSI code page). + final byte[] body = script.getBytes(StandardCharsets.UTF_8); + final byte[] content = new byte[3 + body.length]; + content[0] = (byte) 0xEF; + content[1] = (byte) 0xBB; + content[2] = (byte) 0xBF; + System.arraycopy(body, 0, content, 3, body.length); + final StringBuilder digest = new StringBuilder(); + for (final byte b : java.security.MessageDigest.getInstance("SHA-256").digest(content)) { + digest.append(String.format("%02x", b)); + } + final String certutil = "SHA256 hash of file x:\r\n" + + digest + + "\r\nCertUtil: -hashfile command completed successfully.\r\n"; + + server + // ShellFileCopy locates the Windows directory with a WQL query (no shell involved)... + .enqueue(200, envelope(enumerationDone(instance("Win32_OperatingSystem", "WindowsDirectory", "C:\\Windows")))) + // ...then its first command leg (cleanup + MKDIR) creates the shell... + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue(200, envelope(receiveResponse("", done("CMD-1", 0)))) + .enqueue(200, envelope(signalResponse())) + // ...the digest probe reports an identical remote copy (transfer skipped)... + .enqueue(200, envelope(commandResponse("CMD-2"))) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", "CMD-2", certutil.getBytes(StandardCharsets.UTF_8)), done("CMD-2", 0)) + ) + ) + .enqueue(200, envelope(signalResponse())) + // ...and the -File invocation runs in the SAME shell. + .enqueue(200, envelope(commandResponse("CMD-3"))) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", "CMD-3", "x".repeat(4000).getBytes(StandardCharsets.UTF_8)), + done("CMD-3", 0) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + final CommandResult result = client.powerShell(script).execute(); + assertEquals("x".repeat(4000), result.stdout()); + assertEquals(0, result.exitCode()); + } + + final String command = server + .decryptedRequests() + .stream() + .filter(r -> r.contains("[ScriptBlock]::Create")) + .findFirst() + .orElseThrow(() -> new AssertionError(String.join("\n---\n", server.decryptedRequests()))); + // The invocation reads the content-addressed remote copy and runs its CONTENT as a script + // block — not -File, so the script stays pathless exactly like the encoded form + // ($PSScriptRoot and $MyInvocation.MyCommand.Path are empty either way). + assertTrue(command.contains("powershell.exe -NoProfile -NonInteractive -Command"), command); + // The file is read with a BOM-aware .NET method: Get-Content -Raw does not exist on + // PowerShell 2.0 hosts (Windows Server 2008 R2). + assertTrue(command.contains("[System.IO.File]::ReadAllText('C:\\Windows\\Temp\\winrm-upload-"), command); + assertTrue(command.contains("winrm-powershell." + digest.substring(0, 12) + ".ps1'"), command); + assertFalse(command.contains("-EncodedCommand"), command); + assertFalse(command.contains("-File "), command); + } + @Test void uploadsCarryTheEnvironmentIntoTheShellTheyCreate(@TempDir final java.nio.file.Path tempDir) throws Exception { // .environment(...) combined with .upload(...): the transfer commands run FIRST and are