From b99c3ecef5d8ba6635219f7f5def33f503054691 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 01:17:28 +0200 Subject: [PATCH 1/7] Add powerShell(script): run PowerShell without quoting or escaping (#157) The script travels base64-encoded as powershell.exe -NoProfile -NonInteractive -EncodedCommand, so quotes, pipes, newlines and $variables pass through verbatim. Returns the regular CommandRequest: every option and both terminals apply unchanged. Scripts whose encoded invocation exceeds cmd.exe's 8191-character line limit are rejected with a pointer at command("powershell -File ...").upload(...). Co-Authored-By: Claude Fable 5 --- README.md | 3 + .../org/metricshub/winrm/WinRMClient.java | 54 ++++++++++++++++++ src/site/markdown/commands.md | 36 +++++++++++- src/site/markdown/index.md | 12 +++- .../org/metricshub/winrm/WinRMClientTest.java | 56 +++++++++++++++++++ 5 files changed, 158 insertions(+), 3 deletions(-) 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/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index 87b0ac2..d78b34f 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -22,9 +22,11 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLContext; @@ -78,6 +80,12 @@ public final class WinRMClient implements AutoCloseable { /** Default operation timeout when the builder does not set one. */ private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); + /** The invocation {@link #powerShell(String)} appends the encoded script to. */ + private static final String POWERSHELL_PREFIX = "powershell.exe -NoProfile -NonInteractive -EncodedCommand "; + + /** cmd.exe rejects command lines longer than 8191 characters. */ + private static final int MAX_COMMAND_LINE_LENGTH = 8191; + private final WindowsRemoteExecutor executor; private final String hostname; private final String namespace; @@ -126,6 +134,52 @@ 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. {@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. + *

+ * The encoded invocation must fit in the remote shell's 8191-character command line, which + * caps the script at roughly 3000 characters. Run a longer script from a file instead: + * {@code command("powershell.exe -NoProfile -File c:\\scripts\\collect.ps1") + * .upload(Path.of("c:\\scripts\\collect.ps1"))}. + * + * @param script the PowerShell script to execute, verbatim + * @return the request, to configure and execute + * @throws IllegalArgumentException when the script is blank or too long once encoded + */ + public CommandRequest powerShell(final String script) { + Utils.checkNonBlank(script, "script"); + final String commandLine = POWERSHELL_PREFIX + + Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE)); + if (commandLine.length() > MAX_COMMAND_LINE_LENGTH) { + throw new IllegalArgumentException( + String.format( + "The encoded PowerShell invocation is %d characters, above the remote shell's %d-character " + + "command-line limit: run the script from a file instead — " + + "command(\"powershell.exe -NoProfile -File \").upload().", + commandLine.length(), + MAX_COMMAND_LINE_LENGTH + ) + ); + } + return new CommandRequest(this, commandLine); + } + /** * 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..f4d8a38 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,40 @@ 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. +* **Script size** — the encoded invocation must fit in the remote shell's 8191-character command + line, which caps the script at roughly 3000 characters. Beyond that, `powerShell(...)` throws an + `IllegalArgumentException` and the script should travel as a file instead: + + ```java + client.command("powershell.exe -NoProfile -ExecutionPolicy Bypass -File c:\\scripts\\collect.ps1") + .upload(Path.of("c:\\scripts\\collect.ps1")) + .execute(); + ``` + +* **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..c1c29c8 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,59 @@ 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 powerShellRejectsABlankScript() throws Exception { + try (WinRMClient client = builder(PASSWORD).build()) { + assertThrows(IllegalArgumentException.class, () -> client.powerShell(null)); + assertThrows(IllegalArgumentException.class, () -> client.powerShell(" \n\t")); + } + } + + @Test + void powerShellRejectsAScriptTooLongOnceEncoded() throws Exception { + // 4000 characters encode to ~10667 base64 characters, above cmd.exe's 8191 limit. + final String script = "Write-Output '" + "x".repeat(4000) + "'"; + try (WinRMClient client = builder(PASSWORD).build()) { + final IllegalArgumentException e = assertThrows( + IllegalArgumentException.class, + () -> client.powerShell(script) + ); + assertTrue(e.getMessage().contains("-File"), e.getMessage()); + } + } + @Test void uploadsCarryTheEnvironmentIntoTheShellTheyCreate(@TempDir final java.nio.file.Path tempDir) throws Exception { // .environment(...) combined with .upload(...): the transfer commands run FIRST and are From e4e9e16f6cb6a9539b184b0576fea31aea421a3b Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 01:26:47 +0200 Subject: [PATCH 2/7] Encode the PowerShell script after upload path rewriting (#157) Codex review: powerShell(script).upload(file) encoded the script before ShellFileCopy rewrote the local paths, so the transferred file was never referenced by the running script. The raw script now rides the CommandRequest and is encoded in prepare(), after the rewriting; the over-long-script check still fails eagerly at the powerShell() call. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandRequest.java | 59 ++++++++++++++++- .../org/metricshub/winrm/WinRMClient.java | 31 ++------- src/site/markdown/commands.md | 2 + .../org/metricshub/winrm/WinRMClientTest.java | 65 +++++++++++++++++++ 4 files changed, 131 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index fb3a820..ebd80c8 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,16 @@ 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 "; + + /** cmd.exe rejects command lines longer than 8191 characters. */ + private static final int MAX_COMMAND_LINE_LENGTH = 8191; + 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,10 +86,53 @@ 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, encoded as {@code -EncodedCommand} 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(); + if (powerShell) { + // Fail on an over-long script now, at the call site — not when the command starts. + encodePowerShell(commandLine); + } + } + + /** + * Build the {@code powershell.exe -EncodedCommand} invocation delivering the given script + * verbatim (base64 of its UTF-16LE bytes, the encoding {@code -EncodedCommand} expects). + * + * @param script the PowerShell script + * @return the invocation command line + * @throws IllegalArgumentException when the invocation exceeds the remote shell's command-line limit + */ + private static String encodePowerShell(final String script) { + final String encoded = POWERSHELL_PREFIX + + Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE)); + if (encoded.length() > MAX_COMMAND_LINE_LENGTH) { + throw new IllegalArgumentException( + String.format( + "The encoded PowerShell invocation is %d characters, above the remote shell's %d-character " + + "command-line limit: run the script from a file instead — " + + "command(\"powershell.exe -NoProfile -File \").upload().", + encoded.length(), + MAX_COMMAND_LINE_LENGTH + ) + ); + } + return encoded; } /** @@ -512,10 +565,14 @@ private Prepared prepare(final long timeoutMillis, final long start) environment, TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to copy the local files") ); - actualCommand = String.format("CMD.EXE /C (%s)", updatedCommand); + // A PowerShell script is encoded only now, AFTER the rewriting: the local paths it + // references must be replaced while they are still plain text. + actualCommand = String.format("CMD.EXE /C (%s)", powerShell ? encodePowerShell(updatedCommand) : updatedCommand); actualWorkingDirectory = null; // Already applied when the transfer commands created the shell. actualEnvironment = null; + } else if (powerShell) { + actualCommand = encodePowerShell(commandLine); } final Charset actualCharset = charset != null ? charset : WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET; diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index d78b34f..14aae11 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -22,11 +22,9 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; -import java.util.Base64; import java.util.List; import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLContext; @@ -80,12 +78,6 @@ public final class WinRMClient implements AutoCloseable { /** Default operation timeout when the builder does not set one. */ private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); - /** The invocation {@link #powerShell(String)} appends the encoded script to. */ - private static final String POWERSHELL_PREFIX = "powershell.exe -NoProfile -NonInteractive -EncodedCommand "; - - /** cmd.exe rejects command lines longer than 8191 characters. */ - private static final int MAX_COMMAND_LINE_LENGTH = 8191; - private final WindowsRemoteExecutor executor; private final String hostname; private final String namespace; @@ -149,9 +141,11 @@ public CommandRequest command(final String commandLine) { * } * * The returned request is the same as for {@link #command(String)}: every option and both - * terminals apply unchanged. {@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. + * 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. *

* The encoded invocation must fit in the remote shell's 8191-character command line, which * caps the script at roughly 3000 characters. Run a longer script from a file instead: @@ -164,20 +158,7 @@ public CommandRequest command(final String commandLine) { */ public CommandRequest powerShell(final String script) { Utils.checkNonBlank(script, "script"); - final String commandLine = POWERSHELL_PREFIX - + Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE)); - if (commandLine.length() > MAX_COMMAND_LINE_LENGTH) { - throw new IllegalArgumentException( - String.format( - "The encoded PowerShell invocation is %d characters, above the remote shell's %d-character " + - "command-line limit: run the script from a file instead — " + - "command(\"powershell.exe -NoProfile -File \").upload().", - commandLine.length(), - MAX_COMMAND_LINE_LENGTH - ) - ); - } - return new CommandRequest(this, commandLine); + return new CommandRequest(this, script, true); } /** diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index f4d8a38..3b9e155 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -74,6 +74,8 @@ 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** — the encoded invocation must fit in the remote shell's 8191-character command line, which caps the script at roughly 3000 characters. Beyond that, `powerShell(...)` throws an `IllegalArgumentException` and the script should travel as a file instead: diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index c1c29c8..93325b3 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -316,6 +316,71 @@ void powerShellEncodesTheScriptSoNoEscapingIsNeeded() throws Exception { 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()) { From b712fba96cecfa9970b79f42057476b0731111ec Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 01:33:18 +0200 Subject: [PATCH 3/7] Reserve the CMD.EXE /C wrapper length in the PowerShell length check (#157) Codex review: an encoded invocation of 8179-8191 characters passed the eager check but exceeded cmd.exe's 8191-character limit once upload() added the 13-character CMD.EXE /C ( ) wrapper. The check now always reserves that room, so a script accepted by powerShell() stays valid whether or not upload() is called afterward. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandRequest.java | 16 ++++++++++++---- .../org/metricshub/winrm/WinRMClientTest.java | 11 +++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index ebd80c8..84b20cf 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -58,6 +58,14 @@ public final class CommandRequest { /** 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 + * length check always reserves room for it, so a script accepted by + * {@link WinRMClient#powerShell(String)} stays valid whether or not {@link #upload(Path...)} + * is called afterward. + */ + 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; @@ -121,12 +129,12 @@ private interface StdinSource { private static String encodePowerShell(final String script) { final String encoded = POWERSHELL_PREFIX + Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE)); - if (encoded.length() > MAX_COMMAND_LINE_LENGTH) { + if (encoded.length() > MAX_COMMAND_LINE_LENGTH - CMD_WRAPPER_LENGTH) { throw new IllegalArgumentException( String.format( - "The encoded PowerShell invocation is %d characters, above the remote shell's %d-character " + - "command-line limit: run the script from a file instead — " + - "command(\"powershell.exe -NoProfile -File \").upload().", + "The encoded PowerShell invocation (%d characters, plus the CMD.EXE /C wrapper of an uploaded " + + "request) exceeds the remote shell's %d-character command-line limit: run the script from a " + + "file instead — command(\"powershell.exe -NoProfile -File \").upload().", encoded.length(), MAX_COMMAND_LINE_LENGTH ) diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index 93325b3..b36d976 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -389,6 +389,17 @@ void powerShellRejectsABlankScript() throws Exception { } } + @Test + void powerShellLengthCheckReservesRoomForTheUploadWrapper() throws Exception { + // 3046 characters encode to an 8182-character invocation: below cmd.exe's 8191 limit on + // its own, but over it once an uploaded request adds the 13-character CMD.EXE /C ( ) + // wrapper — the check reserves that room, so upload() can never push a request over. + try (WinRMClient client = builder(PASSWORD).build()) { + assertNotNull(client.powerShell("x".repeat(3040))); + assertThrows(IllegalArgumentException.class, () -> client.powerShell("x".repeat(3046))); + } + } + @Test void powerShellRejectsAScriptTooLongOnceEncoded() throws Exception { // 4000 characters encode to ~10667 base64 characters, above cmd.exe's 8191 limit. From 623ac520de53a28b68b74c6c1af1d18a4e7f1367 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 12:13:14 +0200 Subject: [PATCH 4/7] Run long PowerShell scripts from a transferred file automatically (#157) Instead of rejecting a script whose -EncodedCommand invocation exceeds cmd.exe's 8191-character line limit, powerShell() now handles it itself: the script is written to a local temporary .ps1 file (UTF-8 with a BOM, so -File decodes non-ASCII correctly), transferred through the WinRM channel exactly like an upload(), and run with powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File. The remote copy is content-addressed, so re-running an identical script skips the transfer. The caller never sees the command-line length limit. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandRequest.java | 112 +++++++++++++----- .../org/metricshub/winrm/WinRMClient.java | 13 +- src/site/markdown/commands.md | 18 ++- .../org/metricshub/winrm/WinRMClientTest.java | 106 +++++++++++++++-- 4 files changed, 192 insertions(+), 57 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 84b20cf..5b6fc79 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -55,14 +55,36 @@ public final class CommandRequest { /** 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 -ExecutionPolicy Bypass} is + * needed here and not on the encoded form: the execution policy governs script files + * only. + */ + private static final String POWERSHELL_FILE_PREFIX = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "; + + /** + * 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, + * {@code powershell.exe -File} decodes 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 - * length check always reserves room for it, so a script accepted by - * {@link WinRMClient#powerShell(String)} stays valid whether or not {@link #upload(Path...)} - * is called afterward. + * 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(); @@ -102,8 +124,8 @@ private interface StdinSource { * * @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, encoded as {@code -EncodedCommand} when the command starts (after any - * {@link #upload(Path...)} path rewriting) + * 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) { @@ -112,35 +134,52 @@ private interface StdinSource { this.commandLine = commandLine; this.powerShell = powerShell; this.timeout = client.defaultTimeout(); - if (powerShell) { - // Fail on an over-long script now, at the call site — not when the command starts. - encodePowerShell(commandLine); - } } /** * Build the {@code powershell.exe -EncodedCommand} invocation delivering the given script - * verbatim (base64 of its UTF-16LE bytes, the encoding {@code -EncodedCommand} expects). + * 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 - * @throws IllegalArgumentException when the invocation exceeds the remote shell's command-line limit + * @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)); - if (encoded.length() > MAX_COMMAND_LINE_LENGTH - CMD_WRAPPER_LENGTH) { - throw new IllegalArgumentException( - String.format( - "The encoded PowerShell invocation (%d characters, plus the CMD.EXE /C wrapper of an uploaded " + - "request) exceeds the remote shell's %d-character command-line limit: run the script from a " + - "file instead — command(\"powershell.exe -NoProfile -File \").upload().", - encoded.length(), - MAX_COMMAND_LINE_LENGTH - ) + 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 {@code powershell.exe -File} invocation of the + * remote copy. 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(), + POWERSHELL_FILE_PREFIX + "\"" + 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); } - return encoded; } /** @@ -558,29 +597,44 @@ 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") ); - // A PowerShell script is encoded only now, AFTER the rewriting: the local paths it - // references must be replaced while they are still plain text. - actualCommand = String.format("CMD.EXE /C (%s)", powerShell ? encodePowerShell(updatedCommand) : 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; - } else if (powerShell) { - actualCommand = encodePowerShell(commandLine); } final Charset actualCharset = charset != null ? charset : WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET; diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index 14aae11..d8d5983 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -147,14 +147,17 @@ public CommandRequest command(final String commandLine) { * and 1 when the script ends with a terminating error; call {@code exit } in the script for * a specific exit code. *

- * The encoded invocation must fit in the remote shell's 8191-character command line, which - * caps the script at roughly 3000 characters. Run a longer script from a file instead: - * {@code command("powershell.exe -NoProfile -File c:\\scripts\\collect.ps1") - * .upload(Path.of("c:\\scripts\\collect.ps1"))}. + * 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 run with {@code powershell.exe -File}. + * 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 or too long once encoded + * @throws IllegalArgumentException when the script is blank */ public CommandRequest powerShell(final String script) { Utils.checkNonBlank(script, "script"); diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 3b9e155..9c1ea4c 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -76,16 +76,14 @@ Points to know: 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** — the encoded invocation must fit in the remote shell's 8191-character command - line, which caps the script at roughly 3000 characters. Beyond that, `powerShell(...)` throws an - `IllegalArgumentException` and the script should travel as a file instead: - - ```java - client.command("powershell.exe -NoProfile -ExecutionPolicy Bypass -File c:\\scripts\\collect.ps1") - .upload(Path.of("c:\\scripts\\collect.ps1")) - .execute(); - ``` - +* **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 + run with `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File`. 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(...)`. diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index b36d976..26c2ad5 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -390,27 +390,107 @@ void powerShellRejectsABlankScript() throws Exception { } @Test - void powerShellLengthCheckReservesRoomForTheUploadWrapper() throws Exception { - // 3046 characters encode to an 8182-character invocation: below cmd.exe's 8191 limit on - // its own, but over it once an uploaded request adds the 13-character CMD.EXE /C ( ) - // wrapper — the check reserves that room, so upload() can never push a request over. + 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()) { - assertNotNull(client.powerShell("x".repeat(3040))); - assertThrows(IllegalArgumentException.class, () -> client.powerShell("x".repeat(3046))); + assertEquals("ok", client.powerShell("x".repeat(3040)).execute().stdout()); } + + final String command = server.decryptedRequests().get(1); + assertTrue(command.contains("-EncodedCommand"), command); } @Test - void powerShellRejectsAScriptTooLongOnceEncoded() throws Exception { - // 4000 characters encode to ~10667 base64 characters, above cmd.exe's 8191 limit. + 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 — and run with -File. 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 (powershell.exe -File + // would decode 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 IllegalArgumentException e = assertThrows( - IllegalArgumentException.class, - () -> client.powerShell(script) - ); - assertTrue(e.getMessage().contains("-File"), e.getMessage()); + 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("-File")) + .findFirst() + .orElseThrow(() -> new AssertionError(String.join("\n---\n", server.decryptedRequests()))); + // The invocation runs the content-addressed remote copy of the temporary script file. + assertTrue( + command.contains( + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + + ""C:\\Windows\\Temp\\winrm-upload-" + ) + || + command.contains( + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + + "\"C:\\Windows\\Temp\\winrm-upload-" + ), + command + ); + assertTrue(command.contains("winrm-powershell." + digest.substring(0, 12) + ".ps1"), command); + assertFalse(command.contains("-EncodedCommand"), command); } @Test From a20c0202f3dee6637dd554e4f8f7831576eea3b0 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 12:22:57 +0200 Subject: [PATCH 5/7] Run the transferred script's content as a script block, not -File (#157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: -File gives the script a backing file path, so $PSScriptRoot and $MyInvocation.MyCommand.Path suddenly resolve when a script crosses the length threshold — the fallback was not observably identical to the encoded form. The invocation is now powershell.exe -Command "& ([ScriptBlock]::Create((Get-Content -Raw -LiteralPath '')))": the script stays pathless in both forms, top-level param(...) blocks keep working, and the execution policy (which only governs script files) never applies, so -ExecutionPolicy Bypass is gone too. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandRequest.java | 23 +++++++------- .../org/metricshub/winrm/WinRMClient.java | 11 ++++--- src/site/markdown/commands.md | 11 ++++--- .../org/metricshub/winrm/WinRMClientTest.java | 30 ++++++++----------- 4 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 5b6fc79..5bbc35e 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -57,11 +57,15 @@ public final class CommandRequest { /** * The invocation running a PowerShell script transferred as a file — the automatic fallback - * for scripts too long to ride the command line encoded. {@code -ExecutionPolicy Bypass} is - * needed here and not on the encoded form: the execution policy governs script files - * only. + * 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 script block rather than executed with + * {@code -File}, so the fallback is observably identical to the encoded form: the script has + * no backing file path ({@code $PSScriptRoot} and {@code $MyInvocation.MyCommand.Path} stay + * empty in both forms), a top-level {@code param(...)} block keeps working, and the execution + * policy — which only governs script files — never applies. */ - private static final String POWERSHELL_FILE_PREFIX = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "; + private static final String POWERSHELL_FILE_INVOCATION = "powershell.exe -NoProfile -NonInteractive -Command " + + "\"& ([ScriptBlock]::Create((Get-Content -Raw -LiteralPath '%s')))\""; /** * Local name of the fallback script file. The name is constant: the file is created in a @@ -72,9 +76,8 @@ public final class CommandRequest { private static final String POWERSHELL_FILE_NAME = "winrm-powershell.ps1"; /** - * The UTF-8 byte order mark, prepended to the fallback script file: without it, - * {@code powershell.exe -File} decodes the file with the ANSI code page and mangles every - * non-ASCII character. + * 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 }; @@ -155,8 +158,8 @@ private static String encodePowerShell(final String script) { /** * 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 {@code powershell.exe -File} invocation of the - * remote copy. The local file is deleted before returning; the remote copy is + * {@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) @@ -171,7 +174,7 @@ private String powerShellFromFile(final String script, final long timeoutMillis, Files.write(file, content); return ShellFileCopy.copyLocalFilesToRemote( client.executor(), - POWERSHELL_FILE_PREFIX + "\"" + file + "\"", + String.format(POWERSHELL_FILE_INVOCATION, file), List.of(file.toString()), environment, TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to transfer the PowerShell script") diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index d8d5983..c4611aa 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -150,10 +150,13 @@ public CommandRequest command(final String commandLine) { * 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 run with {@code powershell.exe -File}. - * 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. + * like {@link CommandRequest#upload(Path...)} — and its content run as a script block, which + * keeps the two forms observably identical: {@code $PSScriptRoot} and + * {@code $MyInvocation.MyCommand.Path} stay empty either way, and the host's execution policy + * (which only governs script files) never applies. 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 diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 9c1ea4c..16670d1 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -80,10 +80,13 @@ Points to know: 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 - run with `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File`. 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. + its **content** run as a script block (`[ScriptBlock]::Create`), which keeps the two forms + observably identical: `$PSScriptRoot` and `$MyInvocation.MyCommand.Path` stay empty either way, + a top-level `param(...)` block keeps working, and the host's execution policy (which only + governs script files) never applies. 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(...)`. diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index 26c2ad5..7c3e778 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -415,11 +415,12 @@ void powerShellKeepsAScriptJustUnderTheLimitEncoded() throws Exception { 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 — and run with -File. The caller never sees any of this. + // 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 (powershell.exe -File - // would decode a BOM-less file with the ANSI code page). + // 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; @@ -473,24 +474,17 @@ void powerShellRunsALongScriptFromATransferredFile() throws Exception { final String command = server .decryptedRequests() .stream() - .filter(r -> r.contains("-File")) + .filter(r -> r.contains("[ScriptBlock]::Create")) .findFirst() .orElseThrow(() -> new AssertionError(String.join("\n---\n", server.decryptedRequests()))); - // The invocation runs the content-addressed remote copy of the temporary script file. - assertTrue( - command.contains( - "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + - ""C:\\Windows\\Temp\\winrm-upload-" - ) - || - command.contains( - "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File " + - "\"C:\\Windows\\Temp\\winrm-upload-" - ), - command - ); - assertTrue(command.contains("winrm-powershell." + digest.substring(0, 12) + ".ps1"), command); + // 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); + assertTrue(command.contains("Get-Content -Raw -LiteralPath '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 From 1024b26bc677592a4ebb98c1688c2c8c42a32cb8 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 12:30:29 +0200 Subject: [PATCH 6/7] Dot-source the transferred script block; document the residual difference (#157) Codex review: the call-operator wrapper was itself observable through $MyInvocation.InvocationName/.Line, so the fallback could not honestly be called observably identical. The script block is now dot-sourced, which additionally runs the top level in the session scope exactly like -EncodedCommand does; the documentation now names the one remaining observable difference ($MyInvocation's own metadata) instead of claiming identity. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandRequest.java | 15 +++++++++------ .../java/org/metricshub/winrm/WinRMClient.java | 17 ++++++++++------- src/site/markdown/commands.md | 10 ++++++---- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 5bbc35e..91514a5 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -58,14 +58,17 @@ public final class CommandRequest { /** * 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 script block rather than executed with - * {@code -File}, so the fallback is observably identical to the encoded form: the script has - * no backing file path ({@code $PSScriptRoot} and {@code $MyInvocation.MyCommand.Path} stay - * empty in both forms), a top-level {@code param(...)} block keeps working, and the execution - * policy — which only governs script files — never applies. + * 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. */ private static final String POWERSHELL_FILE_INVOCATION = "powershell.exe -NoProfile -NonInteractive -Command " + - "\"& ([ScriptBlock]::Create((Get-Content -Raw -LiteralPath '%s')))\""; + "\". ([ScriptBlock]::Create((Get-Content -Raw -LiteralPath '%s')))\""; /** * Local name of the fallback script file. The name is constant: the file is created in a diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index c4611aa..3d2e88f 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -150,13 +150,16 @@ public CommandRequest command(final String commandLine) { * 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 script block, which - * keeps the two forms observably identical: {@code $PSScriptRoot} and - * {@code $MyInvocation.MyCommand.Path} stay empty either way, and the host's execution policy - * (which only governs script files) never applies. 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. + * 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 diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 16670d1..71060a4 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -80,10 +80,12 @@ Points to know: 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 script block (`[ScriptBlock]::Create`), which keeps the two forms - observably identical: `$PSScriptRoot` and `$MyInvocation.MyCommand.Path` stay empty either way, - a top-level `param(...)` block keeps working, and the host's execution policy (which only - governs script files) never applies. The remote copy is + 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. From ddb785d4dc4bf5bbf719e802460dae6e297c9108 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 11 Aug 2026 12:37:14 +0200 Subject: [PATCH 7/7] Read the transferred script with a PowerShell 2-compatible API (#157) Codex review: Get-Content -Raw appeared in PowerShell 3.0, so the long-script fallback failed on Windows Server 2008 R2 hosts that the encoded form handles fine. The file is now read with the BOM-aware [System.IO.File]::ReadAllText, available since PowerShell 2.0. Co-Authored-By: Claude Fable 5 --- src/main/java/org/metricshub/winrm/CommandRequest.java | 6 ++++-- src/test/java/org/metricshub/winrm/WinRMClientTest.java | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 91514a5..a26ff5c 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -65,10 +65,12 @@ public final class CommandRequest { * 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. + * 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((Get-Content -Raw -LiteralPath '%s')))\""; + "\". ([ScriptBlock]::Create([System.IO.File]::ReadAllText('%s')))\""; /** * Local name of the fallback script file. The name is constant: the file is created in a diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index 7c3e778..c1d2e3f 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -481,7 +481,9 @@ void powerShellRunsALongScriptFromATransferredFile() throws Exception { // 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); - assertTrue(command.contains("Get-Content -Raw -LiteralPath 'C:\\Windows\\Temp\\winrm-upload-"), 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);