diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index cf09591..fb3a820 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -28,7 +28,9 @@ import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; @@ -51,6 +53,7 @@ public final class CommandRequest { private final WinRMClient client; private final String commandLine; private String workingDirectory; + private final Map environment = new LinkedHashMap<>(); private Duration timeout; private Charset charset; private Charset stdinCharset; @@ -93,6 +96,31 @@ public CommandRequest workingDirectory(final String workingDirectory) { return this; } + /** + * Set an environment variable in the remote shell — the equivalent of {@code winrs -env}. May + * be called several times; insertion order is preserved, and setting the same name again + * replaces its value. Like {@link #workingDirectory(String)}, the environment is shell-scoped: + * the remote command shell is created on the first command a client executes and is reused + * afterward, so this setting takes effect only when this is the client's first command. + * + *
{@code
+	 * CommandResult result = client.command("build.cmd")
+	 * 	.environment("BUILD_NUMBER", "42")
+	 * 	.environment("CONFIG", "release")
+	 * 	.execute();
+	 * }
+ * + * @param name the variable name + * @param value the variable value + * @return this request + */ + public CommandRequest environment(final String name, final String value) { + Utils.checkNonBlank(name, "name"); + Utils.checkNonNull(value, "value"); + environment.put(name, value); + return this; + } + /** * Set the timeout of this command. For {@link #execute()} it is a wall-clock deadline covering * file uploads and the command itself; for {@link #start()} it is an @@ -332,7 +360,13 @@ public CommandResult execute() { if (stdoutConsumer == null && stderrConsumer == null && stdinSource == null) { final WindowsRemoteCommandResult result = client .executor() - .executeCommand(prepared.command, prepared.workingDirectory, prepared.charset, remaining); + .executeCommand( + prepared.command, + prepared.workingDirectory, + prepared.environment, + prepared.charset, + remaining + ); return new CommandResult( result.getStdout(), @@ -396,7 +430,7 @@ public RemoteProcess start() { // round trip (inactivity), not the overall exchange the preparation steps count against. final CommandCursor cursor = client .executor() - .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, !pipeStdin); + .startCommand(prepared.command, prepared.workingDirectory, prepared.environment, timeoutMillis, !pipeStdin); if (stdinSource != null) { try { feedStdin(cursor, prepared.stdinCharset); @@ -429,17 +463,25 @@ public RemoteProcess start() { } } - /** The command, working directory and charset actually sent, after the preparation steps. */ + /** The command, working directory, environment and charset actually sent, after the preparation steps. */ private static final class Prepared { final String command; final String workingDirectory; + final Map environment; final Charset charset; final Charset stdinCharset; - Prepared(final String command, final String workingDirectory, final Charset charset, final Charset stdinCharset) { + Prepared( + final String command, + final String workingDirectory, + final Map environment, + final Charset charset, + final Charset stdinCharset + ) { this.command = command; this.workingDirectory = workingDirectory; + this.environment = environment; this.charset = charset; this.stdinCharset = stdinCharset; } @@ -454,25 +496,31 @@ private Prepared prepare(final long timeoutMillis, final long start) throws IOException, TimeoutException, WindowsRemoteException { String actualCommand = commandLine; String actualWorkingDirectory = workingDirectory; + Map actualEnvironment = environment; if (!uploads.isEmpty()) { // Copy the files through the command shell and rewrite the command to reference the - // remote copies; the transfer commands create the shell, so the working directory no - // longer applies (the shell already exists when the real command runs). + // 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. final List localFiles = uploads.stream().map(Path::toString).collect(Collectors.toList()); final String updatedCommand = 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); actualWorkingDirectory = null; + // Already applied when the transfer commands created the shell. + actualEnvironment = null; } final Charset actualCharset = charset != null ? charset : WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET; final Charset actualStdinCharset = stdinCharset != null ? stdinCharset : actualCharset; - return new Prepared(actualCommand, actualWorkingDirectory, actualCharset, actualStdinCharset); + return new Prepared(actualCommand, actualWorkingDirectory, actualEnvironment, actualCharset, actualStdinCharset); } /** @@ -489,7 +537,7 @@ private CommandResult drainWithCallbacks(final Prepared prepared, final long tim final StringBuilder stderr = new StringBuilder(); try ( CommandCursor cursor = client.executor() - .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, !pipeStdin)) { + .startCommand(prepared.command, prepared.workingDirectory, prepared.environment, timeoutMillis, !pipeStdin)) { if (stdinSource != null) { feedStdin(cursor, prepared.stdinCharset); } diff --git a/src/main/java/org/metricshub/winrm/ShellFileCopy.java b/src/main/java/org/metricshub/winrm/ShellFileCopy.java index a3f477c..388ce89 100644 --- a/src/main/java/org/metricshub/winrm/ShellFileCopy.java +++ b/src/main/java/org/metricshub/winrm/ShellFileCopy.java @@ -30,6 +30,7 @@ import java.util.Base64; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; @@ -141,6 +142,34 @@ public static String copyLocalFilesToRemote( final String command, final List localFiles, final long timeout + ) throws IOException, TimeoutException, WindowsRemoteException { + return copyLocalFilesToRemote(windowsRemoteExecutor, command, localFiles, null, timeout); + } + + /** + * Variant of {@link #copyLocalFilesToRemote(WindowsRemoteExecutor, String, List, long)} that + * also sets environment variables in the remote shell. The transfer commands are the first + * commands the executor runs, so they are what actually creates (and pins the settings of) + * the shell the caller's command will then run in: shell-scoped settings must ride them, or + * they would be silently lost. + * + * @param windowsRemoteExecutor Executor connected to the remote host (mandatory) + * @param command The command referencing the local files (mandatory) + * @param localFiles The list of local files to copy (may be null or empty: no-op) + * @param environment Environment variables of the remote shell, in insertion order (can be + * null or empty for none) + * @param timeout Timeout in milliseconds (throws an IllegalArgumentException if negative or zero) + * @return The command updated with the remote paths of the copied files + * @throws IOException If a local file cannot be read + * @throws TimeoutException To notify userName of timeout + * @throws WindowsRemoteException For any problem encountered on the remote host + */ + public static String copyLocalFilesToRemote( + final WindowsRemoteExecutor windowsRemoteExecutor, + final String command, + final List localFiles, + final Map environment, + final long timeout ) throws IOException, TimeoutException, WindowsRemoteException { Utils.checkNonNull(windowsRemoteExecutor, "windowsRemoteExecutor"); Utils.checkNonNull(command, "command"); @@ -171,6 +200,7 @@ public static String copyLocalFilesToRemote( String .format("forfiles /P \"%s\" /D -%d /C \"cmd /c del /f /q @path\" 2>NUL & ", remoteDirectory, CLEANUP_AGE_DAYS) + WindowsTempShare.buildCreateRemoteDirectoryCommand(remoteDirectory), + environment, "create the remote temporary directory", timeout, start @@ -178,7 +208,14 @@ public static String copyLocalFilesToRemote( String updatedCommand = command; for (final String localFile : localFiles) { - final String remoteFile = copyFile(windowsRemoteExecutor, Paths.get(localFile), remoteDirectory, timeout, start); + final String remoteFile = copyFile( + windowsRemoteExecutor, + Paths.get(localFile), + remoteDirectory, + environment, + timeout, + start + ); updatedCommand = WindowsRemoteProcessUtils.caseInsensitiveReplace(updatedCommand, localFile, remoteFile); } @@ -193,6 +230,7 @@ public static String copyLocalFilesToRemote( * @param windowsRemoteExecutor Executor connected to the remote host * @param localPath The local file to copy * @param remoteDirectory The existing remote directory receiving the file + * @param environment Environment variables of the remote shell (can be null or empty for none) * @param timeout Timeout in milliseconds * @param start Operation start time in milliseconds * @return the path of the file on the remote host @@ -204,6 +242,7 @@ static String copyFile( final WindowsRemoteExecutor windowsRemoteExecutor, final Path localPath, final String remoteDirectory, + final Map environment, final long timeout, final long start ) throws IOException, TimeoutException, WindowsRemoteException { @@ -227,7 +266,7 @@ static String copyFile( final String remoteFile = remoteDirectory + "\\" + contentAddressedName(fileName, content, maxRemoteNameLength(remoteDirectory)); - transferContent(windowsRemoteExecutor, localPath, content, remoteFile, timeout, start); + transferContent(windowsRemoteExecutor, localPath, content, remoteFile, environment, timeout, start); return remoteFile; } @@ -285,12 +324,13 @@ public static void copyLocalFileToRemoteFile( runChecked( windowsRemoteExecutor, WindowsTempShare.buildCreateRemoteDirectoryCommand(remoteDirectory), + null, "create the remote directory", timeout, start ); - transferContent(windowsRemoteExecutor, localPath, content, remoteFile, timeout, start); + transferContent(windowsRemoteExecutor, localPath, content, remoteFile, null, timeout, start); } /** @@ -322,6 +362,7 @@ static void checkEmbeddableRemotePath(final String path) { * @param localPath The local file, for the failure messages * @param content The file content * @param remoteFile The destination path on the remote host + * @param environment Environment variables of the remote shell (can be null or empty for none) * @param timeout Timeout in milliseconds * @param start Operation start time in milliseconds * @throws TimeoutException To notify userName of timeout @@ -332,13 +373,20 @@ private static void transferContent( final Path localPath, final byte[] content, final String remoteFile, + final Map environment, final long timeout, final long start ) throws TimeoutException, WindowsRemoteException { // Skip the transfer if the remote host already has an identical copy. A destination that // exists with a DIFFERENT digest (e.g. a cached copy corrupted or modified in place) is // remembered: it must be repaired by replacement, not trusted. - final Optional existing = remoteDigest(windowsRemoteExecutor, remoteFile, timeout, start); + final Optional existing = remoteDigest( + windowsRemoteExecutor, + remoteFile, + environment, + timeout, + start + ); if (existing.isPresent() && existing.get().matches(content)) { return; } @@ -354,6 +402,7 @@ private static void transferContent( : String.format("IF NOT EXIST \"%s\" TYPE NUL >\"%s\"", remoteFile, remoteFile)) + " & " + digestProbe(remoteFile), + environment, "create an empty file", timeout, start @@ -375,7 +424,7 @@ private static void transferContent( // so a concurrent operation can never invalidate a copy another operation verified. final String stagingFile = String.format("%s.%s.part", remoteFile, uniqueSuffix()); try { - upload(windowsRemoteExecutor, content, stagingFile, localPath, timeout, start); + upload(windowsRemoteExecutor, content, stagingFile, localPath, environment, timeout, start); publish( windowsRemoteExecutor, @@ -384,11 +433,12 @@ private static void transferContent( mismatchedDestination, content, localPath, + environment, timeout, start ); } catch (final TimeoutException | WindowsRemoteException | RuntimeException e) { - bestEffortDelete(windowsRemoteExecutor, timeout, start, stagingFile); + bestEffortDelete(windowsRemoteExecutor, environment, timeout, start, stagingFile); throw e; } @@ -426,6 +476,7 @@ private static WindowsRemoteException integrityCheckFailure( * must be replaced * @param content The expected file content * @param localPath The local file, for the failure message + * @param environment Environment variables of the remote shell (can be null or empty for none) * @param timeout Timeout in milliseconds * @param start Operation start time in milliseconds * @throws TimeoutException To notify userName of timeout @@ -439,6 +490,7 @@ private static void publish( final boolean replaceMismatched, final byte[] content, final Path localPath, + final Map environment, final long timeout, final long start ) throws TimeoutException, WindowsRemoteException { @@ -453,6 +505,7 @@ private static void publish( )) + " & " + digestProbe(remoteFile), + environment, "publish the transferred file", timeout, start @@ -489,6 +542,7 @@ private static String uniqueSuffix() { * @param content The file content * @param remoteFile The target path on the remote host * @param localPath The local file, for the failure message + * @param environment Environment variables of the remote shell (can be null or empty for none) * @param timeout Timeout in milliseconds * @param start Operation start time in milliseconds * @throws TimeoutException To notify userName of timeout @@ -499,6 +553,7 @@ private static void upload( final byte[] content, final String remoteFile, final Path localPath, + final Map environment, final long timeout, final long start ) throws TimeoutException, WindowsRemoteException { @@ -510,7 +565,7 @@ private static void upload( Base64.getEncoder().encodeToString(content), base64File )) { - runChecked(windowsRemoteExecutor, uploadCommand, "upload the file content", timeout, start); + runChecked(windowsRemoteExecutor, uploadCommand, environment, "upload the file content", timeout, start); } final WindowsRemoteCommandResult decoded = run( @@ -518,6 +573,7 @@ private static void upload( String.format("certutil -f -decode \"%s\" \"%s\" && DEL /F /Q \"%s\"", base64File, remoteFile, base64File) + " & " + digestProbe(remoteFile), + environment, "decode the transferred file", timeout, start @@ -538,7 +594,7 @@ private static void upload( throw integrityCheckFailure(localPath, remoteFile, windowsRemoteExecutor); } } catch (final TimeoutException | WindowsRemoteException | RuntimeException e) { - bestEffortDelete(windowsRemoteExecutor, timeout, start, base64File, remoteFile); + bestEffortDelete(windowsRemoteExecutor, environment, timeout, start, base64File, remoteFile); throw e; } @@ -584,6 +640,7 @@ static List buildUploadCommands(final String base64, final String base64 * * @param windowsRemoteExecutor Executor connected to the remote host * @param remoteFile The remote file to hash + * @param environment Environment variables of the remote shell (can be null or empty for none) * @param timeout Timeout in milliseconds * @param start Operation start time in milliseconds * @return the digest of the remote file, or an empty Optional if it couldn't be computed @@ -594,12 +651,14 @@ static List buildUploadCommands(final String base64, final String base64 private static Optional remoteDigest( final WindowsRemoteExecutor windowsRemoteExecutor, final String remoteFile, + final Map environment, final long timeout, final long start ) throws TimeoutException, WindowsRemoteException { final WindowsRemoteCommandResult result = run( windowsRemoteExecutor, digestProbe(remoteFile), + environment, "hash the remote file", timeout, start @@ -674,6 +733,7 @@ static Optional parseCertutilDigest(final String output, final String al * * @param windowsRemoteExecutor Executor connected to the remote host * @param command The command to execute + * @param environment Environment variables of the remote shell (can be null or empty for none) * @param description What the command does, for the timeout and failure messages * @param timeout Timeout in milliseconds * @param start Operation start time in milliseconds @@ -683,11 +743,19 @@ static Optional parseCertutilDigest(final String output, final String al private static void runChecked( final WindowsRemoteExecutor windowsRemoteExecutor, final String command, + final Map environment, final String description, final long timeout, final long start ) throws TimeoutException, WindowsRemoteException { - final WindowsRemoteCommandResult result = run(windowsRemoteExecutor, command, description, timeout, start); + final WindowsRemoteCommandResult result = run( + windowsRemoteExecutor, + command, + environment, + description, + timeout, + start + ); if (result.getStatusCode() != 0) { throw new WindowsRemoteException( @@ -705,15 +773,19 @@ private static void runChecked( private static WindowsRemoteCommandResult run( final WindowsRemoteExecutor windowsRemoteExecutor, final String command, + final Map environment, final String description, final long timeout, final long start ) throws TimeoutException, WindowsRemoteException { for (int attempt = 0;; attempt++) { try { + // The environment rides every leg: whichever command happens to create the shell + // pins it (the others are no-ops), and the caller's command then inherits it. return windowsRemoteExecutor.executeCommand( command, null, + environment, null, TimeoutHelper.getRemainingTime(timeout, start, "No time left to " + description) ); @@ -766,12 +838,14 @@ static boolean isRetryableQuotaRejection(final Exception exception) { * where the original exception must not be masked. * * @param windowsRemoteExecutor Executor connected to the remote host + * @param environment Environment variables of the remote shell (can be null or empty for none) * @param timeout Timeout in milliseconds * @param start Operation start time in milliseconds * @param remoteFiles The remote files to delete */ private static void bestEffortDelete( final WindowsRemoteExecutor windowsRemoteExecutor, + final Map environment, final long timeout, final long start, final String... remoteFiles @@ -782,7 +856,7 @@ private static void bestEffortDelete( } try { - run(windowsRemoteExecutor, "DEL /F /Q" + files, "clean up", timeout, start); + run(windowsRemoteExecutor, "DEL /F /Q" + files, environment, "clean up", timeout, start); } catch (final Exception ignored) { // Cleanup is best-effort: the exception that triggered it matters more } diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index 0748a27..7eee4b3 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -194,6 +194,47 @@ default CommandCursor startCommand( throw new UnsupportedOperationException(getClass().getName() + " does not support pipe-mode standard input."); } + /** + *

+ * Variant of {@link #startCommand(String, String, long, boolean)} that also sets environment + * variables in the remote shell. Like the working directory, the environment is shell-scoped: + * it is honored only when the shell is created, i.e. by the first command this executor runs. + *

+ *

+ * The default implementation delegates to {@link #startCommand(String, String, long, boolean)} + * when no variable is requested — an executor unaware of this variant keeps working for + * ordinary commands — and throws {@link UnsupportedOperationException} otherwise: only + * executors that can put the variables on the wire (such as the built-in lightweight backend) + * implement it, and silently dropping them would run the command in the wrong environment. + *

+ * + * @param command The command to execute + * @param workingDirectory Path of the directory for the spawned process on the remote system (can be null) + * @param environment Environment variables of the remote shell, in insertion order (can be null + * or empty for none) + * @param timeout timeout in milliseconds of each WSMan round trip — the inactivity timeout of + * the stream, not an overall deadline (throws an IllegalArgumentException if negative + * or zero) + * @param consoleModeStdin the value of the {@code WINRS_CONSOLEMODE_STDIN} option: {@code true} + * for console semantics (the historical default), {@code false} for pipe semantics + * @return a cursor over the command output, owning the executor's connection until the command + * completes or the cursor is closed — always close it (try-with-resources) + * @throws TimeoutException when the server does not answer the command startup in time + * @throws WindowsRemoteException For any problem encountered + */ + default CommandCursor startCommand( + final String command, + final String workingDirectory, + final Map environment, + final long timeout, + final boolean consoleModeStdin + ) throws TimeoutException, WindowsRemoteException { + if (environment == null || environment.isEmpty()) { + return startCommand(command, workingDirectory, timeout, consoleModeStdin); + } + throw new UnsupportedOperationException(getClass().getName() + " does not support shell environment variables."); + } + /** * Execute the command on the remote * @@ -213,6 +254,44 @@ WindowsRemoteCommandResult executeCommand( final long timeout ) throws WindowsRemoteException, TimeoutException; + /** + *

+ * Variant of {@link #executeCommand(String, String, Charset, long)} that also sets environment + * variables in the remote shell. Like the working directory, the environment is shell-scoped: + * it is honored only when the shell is created, i.e. by the first command this executor runs. + *

+ *

+ * The default implementation delegates to {@link #executeCommand(String, String, Charset, long)} + * when no variable is requested — an executor unaware of this variant keeps working for + * ordinary commands — and throws {@link UnsupportedOperationException} otherwise: only + * executors that can put the variables on the wire (such as the built-in lightweight backend) + * implement it, and silently dropping them would run the command in the wrong environment. + *

+ * + * @param command The command to execute + * @param workingDirectory Path of the directory for the spawned process on the remote system (can be null) + * @param environment Environment variables of the remote shell, in insertion order (can be null + * or empty for none) + * @param charset The charset decoding the command output; {@code null} uses + * {@link #SHELL_OUTPUT_CHARSET}, which is what the remote shell actually emits + * @param timeout Timeout in milliseconds + * @return The command result + * @throws WindowsRemoteException For any problem encountered + * @throws TimeoutException To notify userName of timeout. + */ + default WindowsRemoteCommandResult executeCommand( + final String command, + final String workingDirectory, + final Map environment, + final Charset charset, + final long timeout + ) throws WindowsRemoteException, TimeoutException { + if (environment == null || environment.isEmpty()) { + return executeCommand(command, workingDirectory, charset, timeout); + } + throw new UnsupportedOperationException(getClass().getName() + " does not support shell environment variables."); + } + /** * Get the hostname. * diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 0258907..e120579 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -31,8 +31,10 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.service.WinRMEndpoint; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; @@ -70,6 +72,7 @@ enum Operation { private final boolean kerberosRealmInferred; private final boolean forwardStdin; private final String directory; + private final Map environment; private final String input; private CliArguments(final Builder builder) { @@ -87,6 +90,7 @@ private CliArguments(final Builder builder) { kerberosRealmInferred = builder.kerberosRealmInferred; forwardStdin = builder.forwardStdin; directory = builder.directory; + environment = builder.environment; input = builder.input; } @@ -160,6 +164,9 @@ private static int parseOption(final Builder builder, final String[] arguments, case "-d": builder.directory = optionValue(arguments, index, option); return nextIndex(argument, index); + case "--env": + parseEnvironmentVariable(builder, optionValue(arguments, index, option), option); + return nextIndex(argument, index); case "--ntlm": builder.ntlm = true; return index + 1; @@ -187,6 +194,20 @@ private static int parseOption(final Builder builder, final String[] arguments, } } + /** + * Record one {@code --env NAME=VALUE} occurrence, winrs-style: the value is split on its FIRST + * {@code =} (so the variable's value may itself contain {@code =}), the name must be non-blank, + * insertion order is preserved, and repeating a name replaces its value. + */ + private static void parseEnvironmentVariable(final Builder builder, final String value, final String option) + throws CliUsageException { + final int separator = value.indexOf('='); + if (separator < 0 || value.substring(0, separator).trim().isEmpty()) { + throw new CliUsageException(option + " requires NAME=VALUE"); + } + builder.environment.put(value.substring(0, separator), value.substring(separator + 1)); + } + private static void parseOperation(final Builder builder, final String name, final List values) throws CliUsageException { if ("shell".equals(name)) { @@ -256,6 +277,9 @@ private static void validate(final Builder builder) throws CliUsageException { if (builder.directory != null && builder.operation == Operation.WQL) { throw new CliUsageException("--directory requires the command or shell subcommand"); } + if (!builder.environment.isEmpty() && builder.operation == Operation.WQL) { + throw new CliUsageException("--env requires the command or shell subcommand"); + } if (builder.operation == Operation.SHELL && builder.timeout < MIN_SHELL_TIMEOUT) { throw new CliUsageException("shell requires --timeout of at least " + MIN_SHELL_TIMEOUT + " milliseconds"); } @@ -496,6 +520,10 @@ String directory() { return directory; } + Map environment() { + return environment; + } + String input() { return input; } @@ -524,6 +552,7 @@ private static final class Builder { private boolean kerberosRealmInferred; private boolean forwardStdin; private String directory; + private final Map environment = new LinkedHashMap<>(); private Integer port; private long timeout = DEFAULT_TIMEOUT; private String input; diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index ff90a8c..fc652a0 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -256,6 +256,7 @@ private static int execute( final int exitCode = remote.executeCommand( arguments.input(), arguments.directory(), + arguments.environment(), arguments.timeout(), arguments.forwardStdin() || !localInput.terminal ? localInput.stream : null, chunk -> { @@ -300,6 +301,7 @@ private static int interactiveShell( final int exitCode = remote.shell( arguments.timeout(), arguments.directory(), + arguments.environment(), localInput.stream, standardOutput, standardError, @@ -498,6 +500,7 @@ private static String help() { " -P, --port Target port (default: HTTP 5985, HTTPS 5986)\n" + " -t, --timeout Operation timeout in milliseconds (default: 60000)\n" + " -d, --directory Working directory of the remote command or shell\n" + + " --env Environment variable of the remote command or shell (repeatable)\n" + " -i, --stdin Forward the local standard input to the remote command\n" + " --https Use HTTPS\n" + " --https-permissive Trust any HTTPS certificate and hostname (insecure)\n" + @@ -543,12 +546,14 @@ interface RemoteOperations extends AutoCloseable { /** * Run the command, forwarding each decoded output chunk to the matching consumer as it * arrives, and return the remote exit code. A non-null {@code workingDirectory} is the - * directory the command starts in. A non-null {@code stdin} is consumed to its end and - * forwarded as the command's standard input. + * directory the command starts in; a non-empty {@code environment} is set in the remote + * shell. A non-null {@code stdin} is consumed to its end and forwarded as the command's + * standard input. */ int executeCommand( String command, String workingDirectory, + Map environment, long timeout, InputStream stdin, Consumer stdoutConsumer, @@ -556,13 +561,14 @@ int executeCommand( ) throws Exception; /** - * Start {@code cmd.exe} on the remote host — in the given working directory when non-null — - * and bridge it to the given local streams until it exits; return its exit code. See - * {@link InteractiveShell}. + * Start {@code cmd.exe} on the remote host — in the given working directory when non-null, + * with the given environment variables when non-empty — and bridge it to the given local + * streams until it exits; return its exit code. See {@link InteractiveShell}. */ int shell( long timeout, String workingDirectory, + Map environment, InputStream localInput, PrintStream out, PrintStream err, @@ -618,6 +624,7 @@ public void streamWql(final String query, final long timeout, final Consumer environment, final long timeout, final InputStream stdin, final Consumer stdoutConsumer, @@ -629,10 +636,13 @@ public int executeCommand( .onStdout(stdoutConsumer) .onStderr(stderrConsumer); // A CLI invocation is one client running one command, so the API's "first command - // only" pinning of the working directory always applies here. + // only" pinning of the working directory and environment always applies here. if (workingDirectory != null) { request.workingDirectory(workingDirectory); } + if (environment != null) { + environment.forEach(request::environment); + } if (stdin != null) { request.stdin(stdin); } @@ -643,6 +653,7 @@ public int executeCommand( public int shell( final long timeout, final String workingDirectory, + final Map environment, final InputStream localInput, final PrintStream out, final PrintStream err, @@ -669,10 +680,13 @@ public int shell( .charset(encoding.charset()) .stdin(); // The session client's first (and only) command is the shell itself, so the - // working directory always takes effect. + // working directory and environment always take effect. if (workingDirectory != null) { shellRequest.workingDirectory(workingDirectory); } + if (environment != null) { + environment.forEach(shellRequest::environment); + } try (RemoteProcess process = shellRequest.start()) { return InteractiveShell.run( process, diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index 3e0a734..35c0115 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -24,6 +24,7 @@ import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.util.Locale; +import java.util.Map; import java.util.UUID; /** @@ -144,12 +145,15 @@ static String release(final String url, final String namespace, final String con /** * Create a command shell. * + * @param environment environment variables of the shell, in insertion order; {@code null} or + * empty omits the {@code rsp:Environment} block * @param codePage the console code page of the shell ({@code WINRS_CODEPAGE}); 0 uses * {@link #CODEPAGE_UTF8}, the default that makes every command's output UTF-8 */ static String createShell( final String url, final String workingDirectory, + final Map environment, final long timeoutMs, final int codePage ) { @@ -161,15 +165,35 @@ static String createShell( final String workingDir = (workingDirectory == null || workingDirectory.trim().isEmpty()) ? "" : "" + escape(workingDirectory) + ""; + // The MS-WSMV Shell_Type schema is a sequence: Environment, then WorkingDirectory, then the + // stream declarations — the order of the protocol's own Create example. return envelopeOpen(true) + header(url, SHELL_RESOURCE_URI, ACTION_CREATE, timeoutMs, null, optionSet) + "" + + environmentBlock(environment) + + workingDir + "stdin" + "stdout stderr" + - workingDir + ""; } + /** The {@code rsp:Environment} block of a Create request, or an empty string for no variables. */ + private static String environmentBlock(final Map environment) { + if (environment == null || environment.isEmpty()) { + return ""; + } + final StringBuilder block = new StringBuilder(""); + for (final Map.Entry variable : environment.entrySet()) { + block + .append("") + .append(escape(variable.getValue())) + .append(""); + } + return block.append("").toString(); + } + /** * Start a command in an existing shell. * diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 1d1fead..329d873 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -327,6 +327,17 @@ public CommandCursor startCommand( final String workingDirectory, final long timeout, final boolean consoleModeStdin + ) throws TimeoutException, WindowsRemoteException { + return startCommand(command, workingDirectory, null, timeout, consoleModeStdin); + } + + @Override + public CommandCursor startCommand( + final String command, + final String workingDirectory, + final Map environment, + final long timeout, + final boolean consoleModeStdin ) throws TimeoutException, WindowsRemoteException { checkNotClosed(); Utils.checkNonNull(command, "command"); @@ -335,7 +346,7 @@ public CommandCursor startCommand( // Shell creation and command startup happen here, on the caller's thread, so failures // surface immediately rather than on the first output chunk. final WsmanClient.RemoteCommand remoteCommand = callStreaming( - () -> client.startCommand(command, workingDirectory, timeout, true, consoleModeStdin) + () -> client.startCommand(command, workingDirectory, environment, timeout, true, consoleModeStdin) ); return new CommandCursor() { @Override @@ -429,6 +440,17 @@ public WindowsRemoteCommandResult executeCommand( final String workingDirectory, final Charset charset, final long timeout + ) throws WindowsRemoteException, TimeoutException { + return executeCommand(command, workingDirectory, null, charset, timeout); + } + + @Override + public WindowsRemoteCommandResult executeCommand( + final String command, + final String workingDirectory, + final Map environment, + final Charset charset, + final long timeout ) throws WindowsRemoteException, TimeoutException { checkNotClosed(); Utils.checkNonNull(command, "command"); @@ -439,7 +461,13 @@ public WindowsRemoteCommandResult executeCommand( return executeWithTimeout( () -> { final long start = Utils.getCurrentTimeMillis(); - final WsmanClient.CommandOutput output = client.executeCommand(command, workingDirectory, charset, timeout); + final WsmanClient.CommandOutput output = client.executeCommand( + command, + workingDirectory, + environment, + charset, + timeout + ); final float executionTime = (Utils.getCurrentTimeMillis() - start) / 1000.0f; return new WindowsRemoteCommandResult(output.stdout, output.stderr, executionTime, output.exitCode); }, diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 0fd96ca..bd85c4f 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -87,12 +87,13 @@ final class WsmanClient implements AutoCloseable { private String pendingAuthorization; private String shellId; - // The shell's working directory is pinned by the FIRST command on this connection and reused - // whenever the shell must be (re)created — e.g. after the server reaped it — so a recreation - // stays invisible to the caller instead of silently moving later commands to the default - // directory. Guarded by connectionPermit, like shellId. + // The shell's working directory and environment variables are pinned by the FIRST command on + // this connection and reused whenever the shell must be (re)created — e.g. after the server + // reaped it — so a recreation stays invisible to the caller instead of silently moving later + // commands to the default directory or environment. Guarded by connectionPermit, like shellId. private String shellWorkingDirectory; - private boolean shellWorkingDirectoryPinned; + private Map shellEnvironment; + private boolean shellSettingsPinned; // A single NTLM connection is a serial channel: one socket, stateful RC4 ciphers with sequence // numbers, and a single shellId. Concurrent callers (e.g. one executor shared across @@ -425,6 +426,7 @@ static final class CommandOutput { * * @param commandLine the command line to run * @param workingDirectory working directory of the shell (only honored when the shell is created) + * @param environment environment variables of the shell (only honored when the shell is created) * @param charset the charset decoding the output streams; {@code null} uses * {@link WindowsRemoteExecutor#SHELL_OUTPUT_CHARSET} * @param operationTimeoutMs this operation's timeout, driving the WSMan OperationTimeout header @@ -433,13 +435,22 @@ static final class CommandOutput { CommandOutput executeCommand( final String commandLine, final String workingDirectory, + final Map environment, final Charset charset, final long operationTimeoutMs ) throws Exception { final Charset cs = charset != null ? charset : WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET; final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - try (RemoteCommand command = startCommand(commandLine, workingDirectory, operationTimeoutMs, false, true)) { + try ( + RemoteCommand command = startCommand( + commandLine, + workingDirectory, + environment, + operationTimeoutMs, + false, + true + )) { RemoteCommand.Chunk chunk; while ((chunk = command.nextChunk()) != null) { stdout.write(chunk.stdout, 0, chunk.stdout.length); @@ -461,6 +472,7 @@ CommandOutput executeCommand( * * @param commandLine the command line to run * @param workingDirectory working directory of the shell (only honored when the shell is created) + * @param environment environment variables of the shell (only honored when the shell is created) * @param operationTimeoutMs each WSMan round trip's timeout, driving the OperationTimeout * header and the socket read timeout — for a streaming consumer this is the inactivity * timeout: the longest silence tolerated between two responses @@ -474,6 +486,7 @@ CommandOutput executeCommand( RemoteCommand startCommand( final String commandLine, final String workingDirectory, + final Map environment, final long operationTimeoutMs, final boolean failOnQuietTimeout, final boolean consoleModeStdin @@ -484,12 +497,17 @@ RemoteCommand startCommand( boolean opened = false; try { configureTimeouts(operationTimeoutMs, failOnQuietTimeout); - if (!shellWorkingDirectoryPinned) { + if (!shellSettingsPinned) { shellWorkingDirectory = workingDirectory; - shellWorkingDirectoryPinned = true; + // A defensive copy: a recreation must replay exactly what the first command set, not + // whatever the caller's map contains by then. + shellEnvironment = environment == null || environment.isEmpty() + ? null + : new LinkedHashMap<>(environment); + shellSettingsPinned = true; } if (shellId == null) { - createShell(shellWorkingDirectory, operationTimeoutMs, failOnQuietTimeout); + createShell(shellWorkingDirectory, shellEnvironment, operationTimeoutMs, failOnQuietTimeout); } // The caller's timeout may have fired while the Create response was being awaited (socket // reads do not observe interrupts): never START the command after the reported timeout. @@ -503,9 +521,10 @@ RemoteCommand startCommand( } // The server reaped the cached shell between commands (e.g. its IdleTimeout expired on a // long-lived client). The Command was rejected before it could run, so it is safe to - // recreate the shell — with its ORIGINAL working directory — and retry once. + // recreate the shell — with its ORIGINAL working directory and environment — and retry + // once. shellId = null; - createShell(shellWorkingDirectory, operationTimeoutMs, failOnQuietTimeout); + createShell(shellWorkingDirectory, shellEnvironment, operationTimeoutMs, failOnQuietTimeout); checkNotCancelled(); commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout, consoleModeStdin); } @@ -877,10 +896,14 @@ public void close() throws Exception { } } - private void createShell(final String workingDirectory, final long timeoutMs, final boolean failOnQuietTimeout) - throws Exception { + private void createShell( + final String workingDirectory, + final Map environment, + final long timeoutMs, + final boolean failOnQuietTimeout + ) throws Exception { final Document doc = exchange( - Envelopes.createShell(url, workingDirectory, timeoutMs, consoleCodePage), + Envelopes.createShell(url, workingDirectory, environment, timeoutMs, consoleCodePage), "Create shell", timeoutMs, failOnQuietTimeout diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 9f5d136..e886f86 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -42,6 +42,7 @@ takes no argument. | `-P, --port ` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. | | `-t, --timeout ` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#Timeout_semantics). | | `-d, --directory ` | Working directory the remote command or interactive shell starts in, like `winrs -d` (only with `command` and `shell`). Default: the remote user's profile directory. | +| `--env ` | Environment variable set in the remote shell, like `winrs -env` (only with `command` and `shell`). Repeatable — one occurrence per variable; the value is split on the first `=`, so it may itself contain `=`. | | `-i, --stdin` | Forward the local standard input to the remote command (only with `command`); see below. | | `--https` | Connect over HTTPS. | | `--https-permissive` | Trust any HTTPS certificate and hostname. Intentionally insecure: testing and isolated hosts only. Requires `--https`. | diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 8139bc5..63536e9 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -46,6 +46,7 @@ Everything between `command(...)` and `execute()` is optional: | `timeout(Duration)` | the client's timeout | Wall-clock deadline covering file uploads and the command itself with `execute()`; inactivity timeout with `start()`. | | `charset(Charset)` | `UTF-8` | The charset used to decode the command output (see below). | | `workingDirectory(String)` | remote default | Working directory of the remote process. The remote shell is created by the client's **first** command and reused afterward, so this only takes effect on that first command. | +| `environment(String, String)` | none | Environment variable set in the remote shell, like `winrs -env` — call it once per variable, insertion order is preserved. Shell-scoped like `workingDirectory`: only takes effect on the client's **first** command. | | `upload(Path...)` | none | Local files to copy to the host before running (see below). | | `stdin(String)` / `stdin(Path)` / `stdin(InputStream)` | none | Standard input fed to the command — the remote equivalent of a `< file` redirection (see below). | | `stdin()` | console semantics | Declare interactive input through `RemoteProcess.stdin()` (with `start()`): pipe semantics without pre-supplied content (see below). | diff --git a/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java b/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java index b743f62..59f3a9b 100644 --- a/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java +++ b/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java @@ -338,6 +338,86 @@ void transfersEmptyFile() throws Exception { assertFalse(executor.getExecutedCommands().stream().anyMatch(command -> command.contains(" echo "))); } + @Test + void transferCommandsCarryTheShellEnvironment() throws Exception { + // The transfer commands are what actually creates (and pins the settings of) the remote + // shell: the caller's shell-scoped environment must ride every leg, or the command the + // files were uploaded for would silently run without it. + final byte[] content = "env".getBytes(UTF_8); + final Path localFile = tempDir.resolve("env.bat"); + Files.write(localFile, content); + + // Cheap skip path: the remote copy already carries the identical digest + final ScriptedWindowsRemoteExecutor delegate = executorWithTempDirectory() + .expectCommand("certutil -hashfile", hashOutput("SHA256", sha256Hex(content))); + + final Map environment = Map.of("BUILD_NUMBER", "42"); + final List> received = new java.util.ArrayList<>(); + final WindowsRemoteExecutor recording = new WindowsRemoteExecutor() { + @Override + public List> executeWql(final String wqlQuery, final long timeout) { + return delegate.executeWql(wqlQuery, timeout); + } + + @Override + public WindowsRemoteCommandResult executeCommand( + final String command, + final String workingDirectory, + final java.nio.charset.Charset charset, + final long timeout + ) { + throw new AssertionError("The transfer must use the environment-aware entry point: " + command); + } + + @Override + public WindowsRemoteCommandResult executeCommand( + final String command, + final String workingDirectory, + final Map commandEnvironment, + final java.nio.charset.Charset charset, + final long timeout + ) { + received.add(commandEnvironment); + return delegate.executeCommand(command, workingDirectory, charset, timeout); + } + + @Override + public String getHostname() { + return delegate.getHostname(); + } + + @Override + public String getUsername() { + return delegate.getUsername(); + } + + @Override + public char[] getPassword() { + return delegate.getPassword(); + } + + @Override + public void close() { + delegate.close(); + } + }; + + final String updatedCommand = ShellFileCopy.copyLocalFilesToRemote( + recording, + localFile.toString(), + List.of(localFile.toString()), + environment, + TIMEOUT + ); + + assertEquals( + expectedRemoteDirectory() + "\\" + ShellFileCopy.contentAddressedName("env.bat", content), + updatedCommand + ); + // Two legs (cleanup + MKDIR, then the digest probe), each carrying the caller's environment. + assertEquals(List.of(environment, environment), received); + } + @Test void returnsCommandUnchangedWithoutFiles() throws Exception { // No handler registered: any remote interaction would fail the test @@ -356,7 +436,7 @@ void rejectsPathWithoutFileName() { // A root path has no file name component (Path.getFileName() is null) assertThrows( IllegalArgumentException.class, - () -> ShellFileCopy.copyFile(executor, Path.of("C:\\"), "C:\\Windows\\Temp", TIMEOUT, 0L) + () -> ShellFileCopy.copyFile(executor, Path.of("C:\\"), "C:\\Windows\\Temp", null, TIMEOUT, 0L) ); assertTrue(executor.getExecutedCommands().isEmpty()); } diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index a9fbb89..014deca 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -42,6 +42,7 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -793,6 +794,57 @@ public CommandCursor startCommand(final String command, final String workingDire assertThrows(UnsupportedOperationException.class, () -> legacy.startCommand("dir", null, 1000, false)); } + @Test + void executorsUnawareOfEnvironmentVariablesKeepWorkingWhenNoneAreSet() throws Exception { + // A pre-existing executor overrides only the historical entry points: the environment-aware + // defaults must delegate to them when no variable is requested, and must refuse — never + // silently drop the variables — otherwise. + final CommandCursor canned = new CommandCursor() { + @Override + public Chunk next() { + return null; + } + + @Override + public int exitCode() { + return 0; + } + + @Override + public void close() {} + }; + final WindowsRemoteCommandResult cannedResult = new WindowsRemoteCommandResult("out", "", 0.1f, 0); + final WindowsRemoteExecutor legacy = new ScriptedWindowsRemoteExecutor() { + @Override + public CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) { + return canned; + } + + @Override + public WindowsRemoteCommandResult executeCommand( + final String command, + final String workingDirectory, + final java.nio.charset.Charset charset, + final long timeout + ) { + return cannedResult; + } + }; + + assertEquals(canned, legacy.startCommand("dir", null, null, 1000, true)); + assertEquals(canned, legacy.startCommand("dir", null, Map.of(), 1000, true)); + assertEquals(cannedResult, legacy.executeCommand("dir", null, null, StandardCharsets.UTF_8, 1000)); + assertEquals(cannedResult, legacy.executeCommand("dir", null, Map.of(), StandardCharsets.UTF_8, 1000)); + assertThrows( + UnsupportedOperationException.class, + () -> legacy.startCommand("dir", null, Map.of("A", "1"), 1000, true) + ); + assertThrows( + UnsupportedOperationException.class, + () -> legacy.executeCommand("dir", null, Map.of("A", "1"), StandardCharsets.UTF_8, 1000) + ); + } + private static byte[] concat(final byte[] a, final byte[] b) { final byte[] result = new byte[a.length + b.length]; System.arraycopy(a, 0, result, 0, a.length); diff --git a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java index 45882f2..2b0dd66 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java @@ -134,6 +134,8 @@ void commandRequestValidatesItsOptions() { assertThrows(IllegalArgumentException.class, () -> client.command(" ")); final CommandRequest request = client.command("ipconfig"); assertThrows(IllegalArgumentException.class, () -> request.workingDirectory(" ")); + assertThrows(IllegalArgumentException.class, () -> request.environment(" ", "value")); + assertThrows(IllegalArgumentException.class, () -> request.environment("NAME", null)); assertThrows(IllegalArgumentException.class, () -> request.timeout(Duration.ZERO)); assertThrows(IllegalArgumentException.class, () -> request.charset(null)); assertThrows(IllegalArgumentException.class, () -> request.upload((java.nio.file.Path) null)); diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index 9353732..b65038d 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -43,6 +43,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.metricshub.winrm.exceptions.WinRMAuthenticationException; import org.metricshub.winrm.exceptions.WinRMFaultException; import org.metricshub.winrm.exceptions.WinRMTimeoutException; @@ -239,6 +240,111 @@ void commandReturnsTypedResult() throws Exception { assertTrue(requests.get(1).contains("mycommand.exe"), requests.get(1)); } + @Test + void environmentVariablesAreSentInTheCreateShellRequest() throws Exception { + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", "CMD-1", "42".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + final CommandResult result = client + .command("echo %BUILD_NUMBER%") + .environment("BUILD_NUMBER", "42") + .environment("CONFIG", "a" + + "42" + + "a<b&"c"" + + "" + ), + create + ); + // The MS-WSMV Shell_Type schema sequence: Environment, then WorkingDirectory, then the + // stream declarations. + final int environment = create.indexOf(""); + final int workingDirectory = create.indexOf(""); + final int inputStreams = create.indexOf(""); + assertTrue(environment < workingDirectory && workingDirectory < inputStreams, create); + } + + @Test + void uploadsCarryTheEnvironmentIntoTheShellTheyCreate(@TempDir final java.nio.file.Path tempDir) throws Exception { + // .environment(...) combined with .upload(...): the transfer commands run FIRST and are + // what actually creates the shell, so they must carry the environment — the real command + // then inherits it. Without that, the variables would be silently dropped. + final byte[] content = "collect".getBytes(StandardCharsets.UTF_8); + final java.nio.file.Path localFile = tempDir.resolve("collect.bat"); + 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 real command runs in the SAME shell. + .enqueue(200, envelope(commandResponse("CMD-3"))) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", "CMD-3", "done".getBytes(StandardCharsets.UTF_8)), done("CMD-3", 0))) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + final CommandResult result = client + .command(localFile.toString()) + .upload(localFile) + .environment("BUILD_NUMBER", "42") + .execute(); + + assertEquals("done", result.stdout()); + } + + final List requests = server.decryptedRequests(); + final List creates = requests + .stream() + .filter(r -> r.contains("")) + .collect(java.util.stream.Collectors.toList()); + assertEquals(1, creates.size(), () -> String.join("\n---\n", requests)); + assertTrue( + creates.get(0) + .contains("42"), + creates.get(0) + ); + } + @Test void commandDecodesOutputAsUtf8WithoutProbingTheRemoteCodeSet() throws Exception { server @@ -522,7 +628,13 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception { try (WinRMClient client = builder(PASSWORD).build()) { assertEquals( "first", - client.command("first.exe").workingDirectory("C:\\Work").charset(StandardCharsets.UTF_8).execute().stdout() + client + .command("first.exe") + .workingDirectory("C:\\Work") + .environment("BUILD_NUMBER", "42") + .charset(StandardCharsets.UTF_8) + .execute() + .stdout() ); assertEquals("second", client.command("second.exe").charset(StandardCharsets.UTF_8).execute().stdout()); } @@ -540,9 +652,14 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception { .collect(java.util.stream.Collectors.toList()); assertEquals(2, creates.size()); assertEquals(2, requests.stream().filter(r -> r.contains(">second.exe<")).count()); - // The recreated shell keeps the working directory pinned by the FIRST command, even though - // the retried command did not set one. + // The recreated shell keeps the working directory AND the environment pinned by the FIRST + // command, even though the retried command did not set them. assertTrue(creates.get(1).contains("C:\\Work"), creates.get(1)); + assertTrue( + creates.get(1) + .contains("42"), + creates.get(1) + ); } @Test diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java index 68a620a..b66cd72 100644 --- a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java +++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java @@ -158,6 +158,52 @@ void parsesTheRemoteWorkingDirectory() throws Exception { } } + @Test + void parsesRepeatableEnvironmentVariables() throws Exception { + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { + "-h", + "host", + "-u", + "user", + "-p", + "secret", + "--env", + "BUILD_NUMBER=42", + "--env=CONFIG=release", + "--env", + "OPTIONS=a=b", + "command", + "build.cmd" + } + )) { + // Insertion order preserved; the value is split on the FIRST '=' only. + assertEquals(List.of("BUILD_NUMBER", "CONFIG", "OPTIONS"), List.copyOf(parsed.environment().keySet())); + assertEquals("42", parsed.environment().get("BUILD_NUMBER")); + assertEquals("release", parsed.environment().get("CONFIG")); + assertEquals("a=b", parsed.environment().get("OPTIONS")); + } + // A repeated name replaces the value; an empty value is allowed (winrs-style). + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { "-h", "host", "-u", "user", "-p", "secret", "--env", "A=1", "--env", "A=2", "--env", "B=", "shell" } + )) { + assertEquals("2", parsed.environment().get("A")); + assertEquals("", parsed.environment().get("B")); + } + // Without the option, the environment is empty. + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { "-h", "host", "-u", "user", "-p", "secret", "command", "whoami" } + )) { + assertTrue(parsed.environment().isEmpty()); + } + } + @Test void acceptsEveryCommandAlias() throws Exception { for (final String alias : List.of("command", "cmd", "exec", "run")) { @@ -247,6 +293,14 @@ void rejectsInvalidArguments() { "--directory requires the command or shell subcommand", concat(base, "-d", "C:\\build", "wql", "SELECT Name FROM Win32_Service") }, + { "--env requires a value", concat(base, "--env") }, + { "--env requires NAME=VALUE", concat(base, "--env", "NOEQUALS", "command", "whoami") }, + { "--env requires NAME=VALUE", concat(base, "--env", "=value", "command", "whoami") }, + { "--env requires NAME=VALUE", concat(base, "--env", " =value", "command", "whoami") }, + { + "--env requires the command or shell subcommand", + concat(base, "--env", "A=b", "wql", "SELECT Name FROM Win32_Service") + }, { "-P must be between 1 and 65535", concat(base, "-P", "65536", "command", "whoami") }, { "-t must be greater than zero", concat(base, "-t", "0", "command", "whoami") }, { "missing subcommand (wql, command, or shell)", base }, diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 8745d8b..d7ef394 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -56,6 +56,7 @@ void helpAndVersionDoNotConnect() throws Exception { assertTrue(help.stdout.contains("[options] shell")); assertTrue(help.stdout.contains("-P, --port")); assertTrue(help.stdout.contains("-d, --directory")); + assertTrue(help.stdout.contains("--env ")); assertTrue(help.stdout.contains("--kerberos-kdc")); assertTrue(help.stdout.contains("--kerberos-realm")); // The details (streaming behavior, password files, exit codes) live in the online manual. @@ -128,6 +129,27 @@ void passesTheDirectoryOptionThroughAsTheWorkingDirectory() throws Exception { org.junit.jupiter.api.Assertions.assertNull(defaulted.workingDirectory); } + @Test + void passesTheEnvOptionsThroughAsTheShellEnvironment() throws Exception { + final FakeRemote remote = new FakeRemote(); + final Invocation invocation = invoke( + concat(REQUIRED, "--env", "BUILD_NUMBER=42", "--env", "CONFIG=release", "exec", "build.cmd"), + args -> remote + ); + assertEquals(0, invocation.exitCode); + assertEquals(Map.of("BUILD_NUMBER", "42", "CONFIG", "release"), remote.environment); + + // The shell subcommand gets the environment too. + final FakeRemote shellRemote = new FakeRemote(); + assertEquals(0, invoke(concat(REQUIRED, "--env=CONFIG=release", "shell"), args -> shellRemote).exitCode); + assertEquals(Map.of("CONFIG", "release"), shellRemote.environment); + + // Without the option, an empty environment is passed: the remote default applies. + final FakeRemote defaulted = new FakeRemote(); + assertEquals(0, invoke(concat(REQUIRED, "exec", "build.cmd"), args -> defaulted).exitCode); + assertTrue(defaulted.environment.isEmpty()); + } + @Test void sendsTheWorkingDirectoryInTheCreateShellRequest() throws Exception { // Full stack against the in-process WSMan server, through the CLI's real connect factory: @@ -152,6 +174,8 @@ void sendsTheWorkingDirectoryInTheCreateShellRequest() throws Exception { "30000", "-d", "C:\\build", + "--env", + "BUILD_NUMBER=42", "exec", "build.cmd" }, @@ -162,6 +186,11 @@ void sendsTheWorkingDirectoryInTheCreateShellRequest() throws Exception { assertEquals("ok", invocation.stdout); final String create = server.decryptedRequests().get(0); assertTrue(create.contains("C:\\build"), create); + // --env reaches the wire too, as the rsp:Environment block of the same Create request. + assertTrue( + create.contains("42"), + create + ); } } @@ -232,6 +261,8 @@ public int read() { "10000", "-d", "C:\\build", + "--env", + "CONFIG=release", "shell" }, WinRmCli::connect, @@ -244,6 +275,10 @@ public int read() { final String create = requests.get(1); assertTrue(create.contains("1252"), create); assertTrue(create.contains("C:\\build"), create); + assertTrue( + create.contains("release"), + create + ); final String command = requests.get(2); assertTrue(command.contains("cmd.exe /Q"), command); assertTrue(command.contains("FALSE"), command); @@ -665,6 +700,7 @@ private static final class FakeRemote implements WinRmCli.RemoteOperations { private Exception failure; private String command; private String workingDirectory; + private Map environment; private java.io.InputStream forwardedStdin; private boolean shellStarted; private boolean closed; @@ -680,6 +716,7 @@ public void streamWql(final String query, final long timeout, final Consumer environment, final long timeout, final java.io.InputStream stdin, final Consumer stdoutConsumer, @@ -687,6 +724,7 @@ public int executeCommand( ) throws Exception { this.command = command; this.workingDirectory = workingDirectory; + this.environment = environment; this.forwardedStdin = stdin; failIfConfigured(); stdoutChunks.forEach(stdoutConsumer); @@ -698,6 +736,7 @@ public int executeCommand( public int shell( final long timeout, final String workingDirectory, + final Map environment, final java.io.InputStream localInput, final java.io.PrintStream out, final java.io.PrintStream err, @@ -705,6 +744,7 @@ public int shell( ) throws Exception { shellStarted = true; this.workingDirectory = workingDirectory; + this.environment = environment; failIfConfigured(); stdoutChunks.forEach(chunk -> { out.print(chunk);