-
Notifications
You must be signed in to change notification settings - Fork 4
Add powerShell(script): run PowerShell without quoting or escaping #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b99c3ec
e4e9e16
b712fba
623ac52
a20c020
1024b26
ddb785d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <i>content</i> is run as a <b>dot-sourced</b> 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.<digest>.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<String, String> 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<String, String> 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<String> 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an oversized script inspects Useful? React with 👍 / 👎. |
||
| 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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adds a new API option to the quick-start even though the existing quick-start and examples remain valid. The repository rules reserve README changes for updates that invalidate or alter existing material and explicitly prohibit cataloguing minor features there; the full PowerShell documentation already belongs in the site documentation added by this commit, so remove these README lines.
AGENTS.md reference: AGENTS.md:L25-L29
Useful? React with 👍 / 👎.