diff --git a/README.md b/README.md index c52b372..63a6b92 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,20 @@ try (RemoteProcess p = client.command("wevtutil qe System /f:text").start()) { int exitCode = p.waitFor(); // or waitFor(Duration) for a deadline } +// Commands can be fed standard input — pre-supplied (stdin(String|Path|InputStream), +// the remote equivalent of a `< file` redirection)... +CommandResult sorted = client.command("sort").stdin(Path.of("data.txt")).execute(); + +// ...or written interactively through the process handle (flush() delivers, close() is EOF): +try (RemoteProcess p = client.command("some-repl.exe").stdin().start()) { + try (BufferedWriter in = p.stdin()) { + in.write("first request\n"); + in.flush(); + System.out.println(p.stdout().readLine()); + } + p.waitFor(); +} + // Middle ground: tail the output live, keep the blocking terminal and its full result. client.command("longRunningThing.exe") .onStdout(chunk -> log.info(chunk)) @@ -197,11 +211,19 @@ java -jar target/winrm-java--standalone.jar \ exec ipconfig /all ``` +Open an interactive session on the remote host (`winrs`-style, line-oriented): + +```bash +java -jar target/winrm-java--standalone.jar \ + -h server.example.net -u 'DOMAIN\user' -pf password.txt shell +``` + Use `--help` for the option list and `--version` for the build version. The CLI is built on the streaming API: WQL rows are written to stdout as UTF-8 [JSON Lines](https://jsonlines.org/) **as the enumeration pages arrive**, and remote command stdout and stderr are forwarded **live** to the -corresponding local streams while the command runs. Diagnostics go only to stderr, and the exit -codes are stable for scripting. +corresponding local streams while the command runs — with local stdin forwarded to the remote +command when it is piped or redirected (`... command sort < data.txt`). Diagnostics go only to +stderr, and the exit codes are stable for scripting. The full manual — options, password handling, Kerberos configuration, streaming and timeout semantics, exit codes — is the diff --git a/src/main/java/org/metricshub/winrm/ChunkEncoder.java b/src/main/java/org/metricshub/winrm/ChunkEncoder.java new file mode 100644 index 0000000..d272082 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/ChunkEncoder.java @@ -0,0 +1,92 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; + +/** + * Incremental, stateful charset encoding for streamed command input — the mirror image of + * {@link ChunkDecoder}. The text fed to a command's standard input arrives in flush-sized pieces, + * and a per-piece {@code String.getBytes(Charset)} would not encode it the way one whole-string + * encode would: a stateful charset (e.g. UTF-16) writes its byte-order mark on every call instead + * of once, and a surrogate pair split across two pieces becomes two replacement characters. A + * single {@link CharsetEncoder} carried across calls keeps that state: the mark is emitted once, + * and a trailing lone high surrogate is withheld until the character is completed by the next + * piece. Malformed and unmappable input is replaced, matching {@link String#getBytes(Charset)}. + */ +final class ChunkEncoder { + + private final CharsetEncoder encoder; + + // Unencoded tail of the previous piece — a lone high surrogate — replayed in front of the + // next piece. + private char[] pending = new char[0]; + + ChunkEncoder(final Charset charset) { + this.encoder = charset + .newEncoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE); + } + + /** + * Encode the next piece of input, returning the bytes that are complete so far. A trailing + * lone high surrogate is withheld until the next call completes the pair — unless + * {@code endOfInput} is set, which flushes the encoder: the dangling half becomes a + * replacement, exactly as a whole-string encode would render it. + */ + byte[] encode(final String piece, final boolean endOfInput) { + final CharBuffer in = CharBuffer.allocate(pending.length + piece.length()); + in.put(pending); + in.put(piece); + in.flip(); + + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final ByteBuffer out = ByteBuffer.allocate(Math.max(16, in.remaining() * 4)); + CoderResult result; + do { + result = encoder.encode(in, out, endOfInput); + bytes.write(out.array(), 0, out.position()); + out.clear(); + // With REPLACE in force the only non-underflow result is overflow: loop for more room. + } while (result.isOverflow()); + + if (endOfInput) { + do { + result = encoder.flush(out); + bytes.write(out.array(), 0, out.position()); + out.clear(); + } while (result.isOverflow()); + pending = new char[0]; + } else { + // Whatever the encoder left in the input is an incomplete character: carry it over. + pending = new char[in.remaining()]; + in.get(pending); + } + return bytes.toByteArray(); + } +} diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java b/src/main/java/org/metricshub/winrm/CommandCursor.java index 6445d88..b43063a 100644 --- a/src/main/java/org/metricshub/winrm/CommandCursor.java +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java @@ -71,6 +71,66 @@ default Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRem return next(); } + /** + * Cadence variant of {@link #poll(long)}: ask the server to answer within + * {@code askMillis} — the polling cadence — while allowing the answer itself up to + * {@code maxWaitMillis} to arrive. A polling consumer (e.g. an interactive session pump) + * wants short idle rounds, but must not fail the stream when a loaded or distant server + * takes longer than one cadence to get its answer across; {@link #poll(long)} is exactly + * this call with {@code askMillis == maxWaitMillis}. + *

+ * The default implementation delegates to {@link #poll(long)} with the full wait. + * + * @param askMillis when the server should answer at the latest — with output when it has + * any, with the protocol's "nothing yet" otherwise + * @param maxWaitMillis how long to block at most, capped by the cursor's per-round-trip + * timeout + * @return the next chunk of raw output — empty when nothing arrived — or {@code null} once + * the command has completed + * @throws TimeoutException when the server does not even answer the bounded request + * @throws WindowsRemoteException for any other failure while receiving + */ + default Chunk poll(final long askMillis, final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { + return poll(maxWaitMillis); + } + + /** + * Feed standard input to the running command — the WSMan Send operation, carrying the bytes to + * the command's {@code stdin} stream. Input larger than one envelope's worth is split into + * several Send requests automatically. A Send is an ordinary request on the executor's serial + * connection: it alternates with {@link #next()}/{@link #poll(long)} on the caller's thread, + * it never runs concurrently with them. + *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support command input (such as the built-in lightweight backend) implement this method. + * + * @param data the input bytes (possibly empty — with {@code end}, a pure end-of-input Send) + * @param end {@code true} to mark the end of input: the command's stdin then reaches EOF, and + * no further input may be sent + * @throws IllegalStateException when the command has already completed or the cursor is closed + * @throws TimeoutException when the server does not answer the Send in time + * @throws WindowsRemoteException for any other failure while sending + */ + default void send(final byte[] data, final boolean end) throws TimeoutException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support command input."); + } + + /** + * Interrupt the command the way a console Ctrl+C would — the WSMan Signal operation with the + * {@code ctrl_c} code. Unlike {@link #close()}'s terminate Signal, it interrupts the command's + * child process without ending the command itself: the cursor stays fully usable. A no-op once + * the command has completed or the cursor is closed. + *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support it (such as the built-in lightweight backend) implement this method. + * + * @throws TimeoutException when the server does not answer the Signal in time + * @throws WindowsRemoteException for any other failure while signaling + */ + default void interrupt() throws TimeoutException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support command interruption."); + } + /** * Get the command's exit code. * diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 6947515..cf09591 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -20,8 +20,11 @@ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.Charset; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; @@ -42,14 +45,26 @@ */ 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; + private final WinRMClient client; private final String commandLine; private String workingDirectory; private Duration timeout; private Charset charset; + private Charset stdinCharset; private final List uploads = new ArrayList<>(); private Consumer stdoutConsumer; private Consumer stderrConsumer; + private StdinSource stdinSource; + private boolean pipeStdin; + + /** A deferred source of pre-supplied standard input, opened when the command starts. */ + @FunctionalInterface + private interface StdinSource { + InputStream open(Charset charset) throws IOException; + } /** * Create the request. @@ -108,6 +123,27 @@ public CommandRequest charset(final Charset charset) { return this; } + /** + * Set the charset used to encode the command's standard input, when it differs from the + * output charset. Default: the output charset (see {@link #charset(Charset)}). + *

+ * The two directions are not symmetric on Windows. Output follows the shell's console code + * page, which this client pins to UTF-8. Input written to a command created with + * console-mode stdin is converted by the WinRM service with the remote machine's + * ANSI code page instead — so an interactive session (the CLI's {@code shell} + * subcommand) must encode what it sends with that code page, whatever the console code page + * is. Input handed to a command created with pipe semantics (any {@code stdin(...)} on this + * request) reaches the process unconverted and needs no override. + * + * @param charset the input charset + * @return this request + */ + public CommandRequest stdinCharset(final Charset charset) { + Utils.checkNonNull(charset, "charset"); + this.stdinCharset = charset; + return this; + } + /** * Copy local files to the remote host (through the WinRM connection itself) before running * the command. Each reference to a local file path inside the command line is rewritten to @@ -134,6 +170,97 @@ public CommandRequest upload(final Path... files) { return this; } + /** + * Feed the given text to the command's standard input. The text is encoded with the same + * charset used to decode the output (see {@link #charset(Charset)}) and delivered in full — + * split into protocol-sized chunks when large — right after the command starts, ending with + * the end-of-input mark so the remote stdin reaches EOF. + *

+ * Supplying input switches the remote stdin to pipe semantics + * ({@code WINRS_CONSOLEMODE_STDIN=FALSE}): tools like {@code sort} or {@code findstr} consume + * it and terminate on EOF, exactly as with a local {@code <} redirection. + * + *

{@code
+	 * CommandResult result = client.command("sort").stdin("beta\nalpha\n").execute();
+	 * }
+ * + * Works with both {@link #execute()} and {@link #start()}; with {@code start()}, the + * {@link RemoteProcess#stdin()} writer is closed from the start — pre-supplied and interactive + * input are mutually exclusive. The input is delivered before any output is read: feeding a + * large input to a command that floods its output in the meantime can deadlock both sides, + * exactly like the {@link java.lang.Process} pipes — the caller's to avoid. + * + * @param text the input text + * @return this request + */ + public CommandRequest stdin(final String text) { + Utils.checkNonNull(text, "text"); + this.stdinSource = charset -> new ByteArrayInputStream(text.getBytes(charset)); + this.pipeStdin = true; + return this; + } + + /** + * Declare that the command will be fed standard input interactively, through + * {@link RemoteProcess#stdin()}, without pre-supplying it: the remote stdin switches to + * pipe semantics ({@code WINRS_CONSOLEMODE_STDIN=FALSE}), so closing the writer + * actually delivers EOF — a console-mode stdin never reaches EOF for tools like {@code sort} + * or {@code findstr}. + * + *
{@code
+	 * try (RemoteProcess p = client.command("sort").stdin().start()) {
+	 * 	try (BufferedWriter in = p.stdin()) {
+	 * 		in.write("beta\nalpha\n");
+	 * 	} // EOF: sort now sorts and exits
+	 * 	p.waitFor();
+	 * }
+	 * }
+ * + * Only meaningful with {@link #start()} — {@link #execute()} cannot take interactive input and + * rejects a request configured this way. Without any {@code stdin} call, the historical + * console semantics are kept (what a command that never reads input, and the interactive CLI + * shell, want). Like every {@code stdin} variant, the LAST call wins: any previously + * pre-supplied input is discarded. + * + * @return this request + */ + public CommandRequest stdin() { + this.stdinSource = null; + this.pipeStdin = true; + return this; + } + + /** + * Feed the content of the given local file to the command's standard input — the equivalent of + * a local {@code < file} redirection. Same contract as {@link #stdin(String)}, except the bytes + * are sent exactly as stored (no re-encoding). + * + * @param file the local file to read + * @return this request + */ + public CommandRequest stdin(final Path file) { + Utils.checkNonNull(file, "file"); + this.stdinSource = charset -> Files.newInputStream(file); + this.pipeStdin = true; + return this; + } + + /** + * Feed the given stream to the command's standard input, reading it to its end. Same contract + * as {@link #stdin(String)}, except the bytes are sent exactly as read (no re-encoding). The + * stream is consumed when the command starts and closed afterward; it is read on the thread + * driving the execution, so a stream that blocks forever stalls the command. + * + * @param stream the input stream to consume + * @return this request + */ + public CommandRequest stdin(final InputStream stream) { + Utils.checkNonNull(stream, "stream"); + this.stdinSource = charset -> stream; + this.pipeStdin = true; + return this; + } + /** * Register a callback receiving each chunk of standard output as it arrives, while * {@link #execute()} is still running — a middle ground between collecting everything and @@ -185,6 +312,13 @@ public CommandRequest onStderr(final Consumer consumer) { * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure */ public CommandResult execute() { + if (pipeStdin && stdinSource == null) { + // stdin() without content promises interactive input, which only start() can take: a + // command waiting on its never-fed pipe would hang for a whole timeout. + throw new IllegalStateException( + "stdin() without content declares interactive input: use start(), or supply the input to stdin(...)." + ); + } final long start = Utils.getCurrentTimeMillis(); final long timeoutMillis = WinRMClient.toMillis(timeout); try { @@ -195,7 +329,7 @@ public CommandResult execute() { "No time left to execute the command" ); - if (stdoutConsumer == null && stderrConsumer == null) { + if (stdoutConsumer == null && stderrConsumer == null && stdinSource == null) { final WindowsRemoteCommandResult result = client .executor() .executeCommand(prepared.command, prepared.workingDirectory, prepared.charset, remaining); @@ -208,9 +342,10 @@ public CommandResult execute() { ); } - // Callback variant: drain the streaming cursor, delivering each chunk as it arrives. - // The same wall-clock deadline governs, enforced the way the blocking path enforces - // it — a worker runs the exchange and is cancelled when the deadline fires. + // Callback/stdin variant: drain the streaming cursor, delivering each chunk as it + // arrives (input, when supplied, is fed right after startup). The same wall-clock + // deadline governs, enforced the way the blocking path enforces it — a worker runs the + // exchange and is cancelled when the deadline fires. return Utils.execute(() -> drainWithCallbacks(prepared, remaining, start), remaining); } catch (final TimeoutException e) { throw timeoutException(e); @@ -261,8 +396,30 @@ 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); - return new RemoteProcess(cursor, prepared.charset, client.hostname(), timeout); + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, !pipeStdin); + if (stdinSource != null) { + try { + feedStdin(cursor, prepared.stdinCharset); + } catch (final Exception e) { + // The input could not be delivered: terminate the command and release the client's + // connection before reporting — a failed start must not leave an open handle + // behind, and a cleanup failure must not replace the actual one. + try { + cursor.close(); + } catch (final RuntimeException cleanup) { + e.addSuppressed(cleanup); + } + throw e; + } + } + return new RemoteProcess( + cursor, + prepared.charset, + prepared.stdinCharset, + client.hostname(), + timeout, + stdinSource != null + ); } catch (final TimeoutException e) { throw timeoutException(e); } catch (final IOException e) { @@ -278,11 +435,13 @@ private static final class Prepared { final String command; final String workingDirectory; final Charset charset; + final Charset stdinCharset; - Prepared(final String command, final String workingDirectory, final Charset charset) { + Prepared(final String command, final String workingDirectory, final Charset charset, final Charset stdinCharset) { this.command = command; this.workingDirectory = workingDirectory; this.charset = charset; + this.stdinCharset = stdinCharset; } } @@ -312,7 +471,8 @@ private Prepared prepare(final long timeoutMillis, final long start) } final Charset actualCharset = charset != null ? charset : WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET; - return new Prepared(actualCommand, actualWorkingDirectory, actualCharset); + final Charset actualStdinCharset = stdinCharset != null ? stdinCharset : actualCharset; + return new Prepared(actualCommand, actualWorkingDirectory, actualCharset, actualStdinCharset); } /** @@ -329,7 +489,10 @@ 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)) { + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis, !pipeStdin)) { + if (stdinSource != null) { + feedStdin(cursor, prepared.stdinCharset); + } CommandCursor.Chunk chunk; while ((chunk = cursor.next()) != null) { deliver(stdoutDecoder.decode(chunk.stdout()), stdout, stdoutConsumer); @@ -346,6 +509,28 @@ private CommandResult drainWithCallbacks(final Prepared prepared, final long tim } } + /** + * Deliver the pre-supplied input to the started command, buffer by buffer, marking the last + * one as the end of input so the remote stdin reaches EOF. The one-buffer read-ahead makes the + * final Send carry data AND the end mark together (no extra empty round trip), except for an + * empty source, whose single Send only announces the EOF. + */ + private void feedStdin(final CommandCursor cursor, final Charset charset) + throws IOException, TimeoutException, WindowsRemoteException { + try (InputStream input = stdinSource.open(charset)) { + byte[] current = input.readNBytes(STDIN_BUFFER_SIZE); + if (current.length == 0) { + cursor.send(current, true); + return; + } + while (current.length > 0) { + final byte[] next = input.readNBytes(STDIN_BUFFER_SIZE); + cursor.send(current, next.length == 0); + current = next; + } + } + } + /** Append a decoded chunk to the accumulated output and hand it to the callback, when any. */ private static void deliver(final String text, final StringBuilder accumulator, final Consumer consumer) { if (text.isEmpty()) { diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index a50ff41..ea461d9 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -20,8 +20,13 @@ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; import java.io.Reader; +import java.io.UncheckedIOException; +import java.io.Writer; import java.nio.charset.Charset; import java.time.Duration; import java.util.concurrent.TimeoutException; @@ -61,7 +66,15 @@ * throw {@link WinRMTimeoutException} when the command stays silent for a whole timeout; use * {@link #waitFor(Duration)} for an overall deadline. *

- * Threading. A process is not thread-safe: read, wait and close from one thread at a time. + * Writing. {@link #stdin()} feeds the command's standard input: {@code flush()} carries the + * written text to the host as a WSMan Send, {@code close()} marks the end of input (the remote + * stdin then reaches EOF). Writes and reads alternate on the caller's thread, exactly like + * {@link java.lang.Process} pipes — including the classic deadlock, which is the caller's to + * avoid: blocking on a read while the remote command itself is blocked waiting for input (or the + * reverse) hangs both sides until the inactivity timeout fires. + *

+ * Threading. A process is not thread-safe: read, write, wait and close from one thread at a + * time. *

* Failures during consumption are reported through the unchecked * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy, including from the @@ -72,30 +85,63 @@ public final class RemoteProcess implements AutoCloseable { private final CommandCursor cursor; private final String hostname; private final Duration timeout; + private final Charset stdinCharset; private final ChunkDecoder stdoutDecoder; private final ChunkDecoder stderrDecoder; + // Created on first use (see sendStdin): a legal decode-only charset (e.g. x-JISAutoDetect) + // has no encoder, and must not fail a process that only ever READS output. + private ChunkEncoder stdinEncoder; + // Decoded output that has arrived but has not been read yet, per channel. private final StringBuilder stdoutPending = new StringBuilder(); private final StringBuilder stderrPending = new StringBuilder(); + // Written input that has not been flushed to the host yet. + private final StringBuilder stdinPending = new StringBuilder(); + private final BufferedReader stdout; private final BufferedReader stderr; + private final BufferedWriter stdin; // finished = no more protocol fetches may happen: the command completed OR the process was // closed early. exitCode is non-null only when completion was actually observed. private boolean finished; private Integer exitCode; - RemoteProcess(final CommandCursor cursor, final Charset charset, final String hostname, final Duration timeout) { + // The end-of-input Send was already emitted (or the input was pre-supplied by the request): + // no further input may be sent. + private boolean stdinClosed; + + RemoteProcess( + final CommandCursor cursor, + final Charset charset, + final Charset stdinCharset, + final String hostname, + final Duration timeout, + final boolean stdinAlreadySupplied + ) { this.cursor = cursor; this.hostname = hostname; this.timeout = timeout; + this.stdinCharset = stdinCharset; this.stdoutDecoder = new ChunkDecoder(charset); this.stderrDecoder = new ChunkDecoder(charset); this.stdout = new BufferedReader(new ChannelReader(stdoutPending)); this.stderr = new BufferedReader(new ChannelReader(stderrPending)); + this.stdin = new BufferedWriter(new StdinWriter()); + this.stdinClosed = stdinAlreadySupplied; + if (stdinAlreadySupplied) { + // The writer itself must reject writes immediately, not buffer them into the void: + // close it for real. No request leaves here — ending an already-ended input is a no-op. + try { + stdin.close(); + } catch (final IOException e) { + // Unreachable: closing the empty writer performs no I/O (see sendStdin). + throw new UncheckedIOException(e); + } + } } /** @@ -120,6 +166,60 @@ public BufferedReader stderr() { return stderr; } + /** + * Get the standard input of the remote command. Written text is buffered locally until + * {@code flush()}, which carries it to the host as one WSMan Send (encoded with the request's + * charset); {@code close()} sends the final chunk flagged as the end of input, after which the + * remote stdin reaches EOF. Always the same writer instance; closing it does not close the + * process — but unlike the readers it does talk to the host, so close it before (or via) the + * process itself, not after. + *

+ * When the request pre-supplied the input ({@code stdin(...)} on the builder), that input was + * already delivered in full: this writer is then closed from the start. + *

+ * A command that must observe the EOF — a filter like {@code sort} that only acts once + * its input ends — needs pipe semantics: declare the interactive input with the no-argument + * {@link CommandRequest#stdin()} on the builder. Without it the remote stdin keeps the + * historical console semantics, where writes are delivered but the end of input is not. + *

+ * Failures while sending are reported through the unchecked + * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy; writing after the end + * of input or after the command completed throws {@link IllegalStateException}. + * + * @return the standard input writer + */ + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "The writer IS the API: it feeds the process's standard input, exactly like " + + + "Process.getOutputStream()") + public BufferedWriter stdin() { + return stdin; + } + + /** + * Interrupt the command the way a console Ctrl+C would — the WSMan {@code ctrl_c} Signal. It + * interrupts the command's child process without terminating the command or this process + * handle: the process stays fully usable, which is what an interactive session needs. A no-op + * once the command has completed or the process is closed. + * + * @throws WinRMTimeoutException when the server does not answer the Signal in time + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public synchronized void interrupt() { + if (finished) { + return; + } + try { + cursor.interrupt(); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("The interrupt Signal was not answered within %s on %s", timeout, hostname), + e + ); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } + /** * Wait for the command to complete, buffering any unread output in the meantime (read it * afterward from {@link #stdout()}/{@link #stderr()}). @@ -135,6 +235,52 @@ public synchronized int waitFor() { return exitCodeValue(); } + /** + * Advance the stream by at most one bounded protocol round trip: ask the server to + * answer within the given wait — with output when it has any, with the protocol's "nothing + * yet" otherwise — buffer whatever arrives for the readers, and report whether the command + * has completed. An empty answer is not a failure: the process stays fully usable, so a + * polling consumer (e.g. an interactive session pump) never trips the inactivity timeout + * while idle. Unlike {@link #waitFor(Duration)}, which keeps polling until its deadline, this + * returns as soon as the server answers: output that is ready arrives (and can be read) + * immediately. + *

+ * The wait is the polling cadence, not a hard bound on the round trip: how long the + * answer itself may take to arrive is governed by the request timeout (the inactivity + * timeout), so one slow answer from a loaded server does not fail the stream — use + * {@link #waitFor(Duration)} when a hard deadline is needed. The request timeout also + * caps the cadence, and a resulting budget too short for a network round trip — the + * WSMan service holds a bounded Receive for at least 500 ms before answering "nothing yet" — + * is waited out locally without touching the wire: the stream then does not advance. So poll + * with waits of about a second or more, on a request whose timeout is at least as long; + * a shorter request timeout leaves {@link #waitFor()} and the readers as the ways to advance. + * + * @param maxWait when the server should answer at the latest (at least one millisecond) + * @return {@code true} when the command has completed — the exit code is then available from + * {@link #exitCode()} — {@code false} when it is still running + * @throws WinRMTimeoutException when the server stays silent for a whole inactivity timeout + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public synchronized boolean poll(final Duration maxWait) { + WinRMClient.checkPositive(maxWait, "maxWait"); + if (!finished) { + final long ask = WinRMClient.toMillis(maxWait); + final CommandCursor.Chunk chunk; + try { + chunk = cursor.poll(ask, Math.max(ask, WinRMClient.toMillis(timeout))); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("Command produced no output within %s on %s", timeout, hostname), + e + ); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + absorb(chunk); + } + return finished; + } + /** * Wait at most the given duration for the command to complete — an overall deadline, on top of * the per-response inactivity timeout. The remaining wait is a hard bound on the active @@ -263,6 +409,78 @@ private void absorb(final CommandCursor.Chunk chunk) { } } + /** + * Flush the buffered input to the host — one WSMan Send, flagged as the end of input when + * {@code end} is set. Holds the monitor. Ending the input is idempotent; a plain flush with + * nothing buffered is a no-op. Once the command completed (or the process was closed), ending + * the input touches nothing — the cursor already released the connection, and closing a + * writer must never turn into a stray request or a failure hiding a known exit code (input it + * still held is discarded, like a {@link java.lang.Process} pipe's) — while flushing actual + * input is refused: it can no longer be delivered. + */ + private synchronized void sendStdin(final boolean end) { + if (stdinClosed) { + if (end) { + return; + } + throw new IllegalStateException("The command's standard input has already been closed."); + } + // Stateful encoding: a charset mark is emitted once (not per flush) and a surrogate pair + // split across two flushes is withheld until complete, exactly as one whole-string encode. + // The encoder is created on first actual input: a decode-only charset throws on + // newEncoder(), which must only ever hit a caller actually writing input — never a process + // that only reads output, and never a bare close of the untouched writer. + final byte[] bytes; + if (stdinEncoder == null && stdinPending.length() == 0) { + bytes = new byte[0]; + } else { + if (stdinEncoder == null) { + stdinEncoder = new ChunkEncoder(stdinCharset); + } + bytes = stdinEncoder.encode(stdinPending.toString(), end); + stdinPending.setLength(0); + } + if (finished) { + if (end) { + stdinClosed = true; + return; + } + if (bytes.length == 0) { + return; + } + throw new IllegalStateException("The command has completed: its standard input is closed."); + } + if (bytes.length == 0 && !end) { + return; + } + if (end) { + stdinClosed = true; + } + try { + cursor.send(bytes, end); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("The command input was not accepted within %s on %s", timeout, hostname), + e + ); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } + + /** Buffer written input until the next flush. Holds the monitor. */ + private synchronized void bufferStdin(final char[] cbuf, final int off, final int len) { + if (stdinClosed) { + throw new IllegalStateException("The command's standard input has already been closed."); + } + stdinPending.append(cbuf, off, len); + } + + /** Whether the channel's buffer holds decoded output ready to be read without a round trip. */ + private synchronized boolean hasPending(final StringBuilder pending) { + return pending.length() > 0; + } + /** Serve a read from the channel's buffer, advancing the Receive loop while it is empty. */ private synchronized int read(final StringBuilder pending, final char[] cbuf, final int off, final int len) { while (pending.length() == 0 && !finished) { @@ -297,10 +515,40 @@ public int read(final char[] cbuf, final int off, final int len) { return RemoteProcess.this.read(pending, cbuf, off, len); } + @Override + public boolean ready() { + // Buffered output can be read without a protocol round trip. This is what lets a polling + // consumer (e.g. the CLI's interactive shell pump) drain everything a bounded wait + // absorbed without ever blocking on the wire. + return RemoteProcess.this.hasPending(pending); + } + @Override public void close() { // Closing a reader does not close the process: the RemoteProcess owns the lifecycle, so // each reader can sit in its own try-with-resources while the process lives on. } } + + /** + * The command's standard input as a {@link Writer}: writes buffer locally, {@code flush()} + * emits one WSMan Send, {@code close()} emits the final Send flagged as the end of input. + */ + private final class StdinWriter extends Writer { + + @Override + public void write(final char[] cbuf, final int off, final int len) { + bufferStdin(cbuf, off, len); + } + + @Override + public void flush() { + sendStdin(false); + } + + @Override + public void close() { + sendStdin(true); + } + } } diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index e986b62..87b0ac2 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -240,6 +240,7 @@ public static final class Builder { private List authentication; private Path ticketCache; private boolean trustAllCertificates; + private int consoleCodePage; private SSLContext sslContext; private Duration timeout = DEFAULT_TIMEOUT; @@ -396,6 +397,28 @@ public Builder timeout(final Duration timeout) { return this; } + /** + * Set the console code page of the remote command shell. Default: 65001 (UTF-8), which makes + * command output UTF-8 whatever the remote locale — the right choice for reading output and + * for piping data to a program. + *

+ * An interactive session needs a different setting: under code page 65001 a remote + * {@code cmd.exe} decodes the command lines it reads from its standard input one byte at a + * time, so every non-ASCII character is lost. Pin a single-byte code page (typically the + * remote machine's ANSI one, {@code Win32_OperatingSystem.CodeSet}) and use the matching + * charset for both directions, as the CLI's {@code shell} subcommand does. + * + * @param consoleCodePage the console code page (e.g. 1252), or 0 for the default + * @return this builder + */ + public Builder consoleCodePage(final int consoleCodePage) { + if (consoleCodePage < 0) { + throw new IllegalArgumentException("consoleCodePage must not be negative."); + } + this.consoleCodePage = consoleCodePage; + return this; + } + /** * Build the client. This does not connect yet: the connection is established and * authenticated by the first operation. @@ -431,7 +454,8 @@ public WinRMClient build() { ticketCache, authentications, sslContext, - trustAllCertificates + trustAllCertificates, + consoleCodePage ); return new WinRMClient(executor, endpoint.getHostname(), endpoint.getNamespace(), timeout); } catch (final WinRMException e) { diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index ebf8dc9..0748a27 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -154,6 +154,46 @@ default CommandCursor startCommand(final String command, final String workingDir throw new UnsupportedOperationException(getClass().getName() + " does not support streaming command execution."); } + /** + *

+ * Variant of {@link #startCommand(String, String, long)} making the {@code WINRS_CONSOLEMODE_STDIN} + * option explicit. The three-argument variant keeps the historical console semantics + * ({@code TRUE}); pass {@code false} when the command will be fed standard input through + * {@link CommandCursor#send(byte[], boolean)} and must see it as an ordinary pipe — a + * console-mode stdin never reaches EOF for tools like {@code sort} or {@code more}. + *

+ *

+ * The default implementation delegates console-mode requests to + * {@link #startCommand(String, String, long)} — an executor that overrides only the historical + * three-argument variant keeps working for ordinary commands — and throws + * {@link UnsupportedOperationException} for pipe mode: only executors that support command + * input (such as the built-in lightweight backend) implement it. + *

+ * + * @param command The command to execute + * @param workingDirectory Path of the directory for the spawned process on the remote system (can be null) + * @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 long timeout, + final boolean consoleModeStdin + ) throws TimeoutException, WindowsRemoteException { + if (consoleModeStdin) { + return startCommand(command, workingDirectory, timeout); + } + throw new UnsupportedOperationException(getClass().getName() + " does not support pipe-mode standard input."); + } + /** * Execute the command on the remote * diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 4335e47..1de7806 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -43,11 +43,19 @@ enum Operation { HELP, VERSION, WQL, - COMMAND + COMMAND, + SHELL } static final long DEFAULT_TIMEOUT = 60_000L; + /** + * Smallest usable {@code --timeout} for the interactive shell: it caps each bounded poll of + * the session pump, and a poll below the WSMan floor (a 500 ms server-side hold plus transit + * slack) never reaches the wire — the shell would spin locally without ever fetching output. + */ + static final long MIN_SHELL_TIMEOUT = 1_000L; + private final Operation operation; private final String hostname; private final String username; @@ -60,6 +68,7 @@ enum Operation { private final String kerberosKdc; private final String kerberosRealm; private final boolean kerberosRealmInferred; + private final boolean forwardStdin; private final String input; private CliArguments(final Builder builder) { @@ -75,6 +84,7 @@ private CliArguments(final Builder builder) { kerberosKdc = builder.kerberosKdc; kerberosRealm = builder.kerberosRealm; kerberosRealmInferred = builder.kerberosRealmInferred; + forwardStdin = builder.forwardStdin; input = builder.input; } @@ -162,6 +172,10 @@ private static int parseOption(final Builder builder, final String[] arguments, case "--https-permissive": builder.permissiveHttps = true; return index + 1; + case "--stdin": + case "-i": + builder.forwardStdin = true; + return index + 1; default: throw new CliUsageException("unknown option " + safeOptionName(argument)); } @@ -169,6 +183,13 @@ private static int parseOption(final Builder builder, final String[] arguments, private static void parseOperation(final Builder builder, final String name, final List values) throws CliUsageException { + if ("shell".equals(name)) { + builder.operation = Operation.SHELL; + if (!values.isEmpty()) { + throw new CliUsageException("shell takes no argument"); + } + return; + } if ("wql".equals(name)) { builder.operation = Operation.WQL; builder.input = String.join(" ", values); @@ -186,9 +207,9 @@ private static void validate(final Builder builder) throws CliUsageException { return; } if (builder.operation == null) { - throw new CliUsageException("missing subcommand (wql or command)"); + throw new CliUsageException("missing subcommand (wql, command, or shell)"); } - if (isBlank(builder.input)) { + if (builder.operation != Operation.SHELL && isBlank(builder.input)) { throw new CliUsageException( builder.operation == Operation.WQL ? "wql requires a query" : "command requires a command line" ); @@ -220,6 +241,12 @@ private static void validate(final Builder builder) throws CliUsageException { if (builder.permissiveHttps && !builder.https) { throw new CliUsageException("--https-permissive requires --https"); } + if (builder.forwardStdin && builder.operation != Operation.COMMAND) { + throw new CliUsageException("--stdin requires the command subcommand"); + } + if (builder.operation == Operation.SHELL && builder.timeout < MIN_SHELL_TIMEOUT) { + throw new CliUsageException("shell requires --timeout of at least " + MIN_SHELL_TIMEOUT + " milliseconds"); + } } private static void validateKerberosConfiguration(final Builder builder) throws CliUsageException { @@ -383,7 +410,9 @@ private static boolean isSubcommand(final String value) { || "exec".equals(value) || - "run".equals(value); + "run".equals(value) + || + "shell".equals(value); } private static boolean isBlank(final String value) { @@ -447,6 +476,10 @@ boolean kerberosRealmInferred() { return kerberosRealmInferred; } + boolean forwardStdin() { + return forwardStdin; + } + String input() { return input; } @@ -473,6 +506,7 @@ private static final class Builder { private String kerberosKdc; private String kerberosRealm; private boolean kerberosRealmInferred; + private boolean forwardStdin; private Integer port; private long timeout = DEFAULT_TIMEOUT; private String input; diff --git a/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java new file mode 100644 index 0000000..89c5a57 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/cli/InteractiveShell.java @@ -0,0 +1,446 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.Console; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.io.Reader; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.UnaryOperator; +import org.metricshub.winrm.RemoteProcess; + +/** + * The protocol pump behind the CLI's {@code shell} subcommand: bridges a {@link RemoteProcess} + * (typically a remote {@code cmd.exe}) to local streams until the remote shell exits, and returns + * its exit code. + *

+ * The pump is single-threaded on the wire — the WinRM connection stays strictly serial. + * Each round of the loop forwards the queued local input (one WSMan Send, bounded per round), + * polls the remote output for a short bounded wait, and writes whatever arrived to the local + * streams; the poll cadence is the interaction latency. An idle session never trips the + * inactivity timeout: every poll round trip completes with output or the protocol's "nothing + * yet" answer. + *

+ * The only helper thread reads the local input into a bounded queue, because a blocking read of + * {@code System.in} must not stall the pump; it never touches the connection. Input is + * line-oriented (like {@code winrs}): a terminal hands lines out after Enter, and they + * are forwarded CRLF-terminated. Redirected input flows through the same path in bounded pieces, + * so even a giant newline-free record streams through flat memory. + *

+ * Local end of input (Ctrl+Z then Enter on Windows, Ctrl+D elsewhere) closes the remote stdin — + * the final Send is flagged {@code End} — after which {@code cmd.exe} exits on its own. Setting + * the interrupt flag (wired to Ctrl+C by the CLI) forwards a WSMan {@code ctrl_c} Signal, which + * interrupts the remote child process without ending the session. + */ +final class InteractiveShell { + + /** + * Poll cadence of the CLI session. It paces the IDLE rounds only — a poll returns as soon as + * the server has output, and the poll following a forwarded line is issued immediately — so + * typed input echoes back with sub-second latency while idle rounds stay cheap. The cadence + * cannot be lower: the WSMan service holds a bounded Receive for at least 500 ms (its + * OperationTimeout floor), and the poll needs transit slack on top. + */ + static final long DEFAULT_POLL_MILLIS = 1_000L; + + /** + * How much input one round forwards at most (one Send's worth of characters). Redirected + * input can queue pieces much faster than the wire consumes them: the bound keeps the local + * buffering flat and, above all, keeps the rounds alternating — a fire hose of input must not + * starve the output side of the pump. + */ + static final int MAX_INPUT_CHARS_PER_ROUND = 32 * 1024; + + /** + * Largest single piece the local reader thread queues. A record longer than this (a giant + * newline-free payload in a redirected input) is queued in pieces of this size instead of + * being materialized whole, so the memory bound holds by CHARACTERS, not by lines. + */ + static final int MAX_QUEUED_PIECE_CHARS = 4 * 1024; + + /** + * How many pieces the local reader thread may queue ahead of the pump before it blocks — + * backpressure toward the local stdin, so a redirected file streams through bounded memory + * instead of being swallowed whole. + */ + private static final int INPUT_QUEUE_CAPACITY = 256; + + /** What terminates a line for the remote {@code cmd.exe}. */ + private static final String LINE_SEPARATOR = "\r\n"; + + private InteractiveShell() {} + + /** + * The local input available to the pump, without ever blocking it. Seam between the pump's + * deterministic protocol loop and the helper thread that feeds it in production. + */ + interface InputSource { + /** + * @return the next queued piece of local input — line terminators already normalized to + * CRLF and included — or {@code null} when nothing is available right now + * @throws IOException when reading the local input failed: the session must fail rather + * than pass the truncated input off as the complete one + */ + String nextPiece() throws IOException; + + /** + * @return whether the local input reached its end and every queued piece was consumed + */ + boolean endOfInput(); + } + + /** + * The charset the local standard input is actually encoded with. {@code Charset.defaultCharset()} + * is wrong for a Windows console on modern JDKs: the JVM default is UTF-8 there while the + * console feeds {@code System.in} with its OEM code page. The JDK exposes the real answer + * through {@code stdin.encoding} (recent JDKs), {@code sun.stdin.encoding} (Windows consoles + * on older JDKs), {@code Console.charset()} (Java 17+, reached reflectively — this code + * targets Java 11), and {@code native.encoding} (Java 18+), tried in that order before + * falling back to the process default. + */ + static Charset localInputCharset(final UnaryOperator systemProperty, final Console console) { + for (final String property : new String[] { "stdin.encoding", "sun.stdin.encoding" }) { + final Charset charset = charsetOf(systemProperty.apply(property)); + if (charset != null) { + return charset; + } + } + if (console != null) { + try { + final Object charset = Console.class.getMethod("charset").invoke(console); + if (charset instanceof Charset) { + return (Charset) charset; + } + } catch (final ReflectiveOperationException | RuntimeException ignored) { + // Java 11-16: no Console.charset() — keep probing. + } + } + final Charset nativeCharset = charsetOf(systemProperty.apply("native.encoding")); + return nativeCharset != null ? nativeCharset : Charset.defaultCharset(); + } + + /** The charset of the given name, or {@code null} when absent, unknown, or unsupported. */ + private static Charset charsetOf(final String name) { + if (name == null) { + return null; + } + try { + return Charset.forName(name); + } catch (final RuntimeException e) { + return null; + } + } + + /** + * Bridge the process to the given local streams until the remote command exits. + * + * @param process the running remote shell + * @param localInput the local input to forward, read on a helper thread + * @param out where the remote standard output goes + * @param err where the remote standard error goes + * @param interruptRequested set externally (e.g. by a Ctrl+C handler) to forward a + * {@code ctrl_c} Signal on the next round; cleared once forwarded + * @param pollMillis the poll cadence in milliseconds + * @param endOfInputCommand what to send when the local input ends, for a remote command whose + * stdin has no end of input (console mode); {@code null} closes the remote stdin instead + * @return the remote command's exit code + * @throws IOException when forwarding the local input fails + */ + static int run( + final RemoteProcess process, + final InputStream localInput, + final PrintStream out, + final PrintStream err, + final AtomicBoolean interruptRequested, + final long pollMillis, + final String endOfInputCommand + ) throws IOException { + final QueuedInputSource pieces = new QueuedInputSource(); + final Thread reader = new Thread(() -> pieces.readAll(localInput), "winrm-shell-stdin"); + reader.setDaemon(true); + reader.start(); + return bridge(process, pieces, out, err, interruptRequested, pollMillis, endOfInputCommand); + } + + /** + * The pump itself, deterministic given its inputs: forward queued local input (one Send per + * round at most), poll the remote output for a short bounded wait, forward whatever arrived to + * the local streams — until the remote command completes. + * + * @param process the running remote shell + * @param pieces the local input pieces + * @param out where the remote standard output goes + * @param err where the remote standard error goes + * @param interruptRequested checked (and cleared) every round; forwards a {@code ctrl_c} Signal + * @param pollMillis the poll cadence in milliseconds + * @param endOfInputCommand what to send when the local input ends, for a remote command whose + * stdin has no end of input (console mode); {@code null} closes the remote stdin instead + * @return the remote command's exit code + * @throws IOException when forwarding the local input fails + */ + static int bridge( + final RemoteProcess process, + final InputSource pieces, + final PrintStream out, + final PrintStream err, + final AtomicBoolean interruptRequested, + final long pollMillis, + final String endOfInputCommand + ) throws IOException { + // What is still owed to the remote stdin, and whether the last character forwarded ended + // a line — the synthetic exit below must never be glued onto an unterminated record. + final OutgoingInput outgoing = new OutgoingInput(); + boolean eofForwarded = false; + boolean completed = false; + while (!completed) { + if (interruptRequested.getAndSet(false)) { + process.interrupt(); + } + if (!eofForwarded) { + try { + eofForwarded = forwardLocalInput(pieces, process.stdin(), outgoing, endOfInputCommand); + } catch (final IllegalStateException e) { + // The command completed while this input was being queued (the final Receive + // beat it): nothing can consume it anymore. Stop forwarding — the next poll + // observes the completion and the exit code is reported normally. + eofForwarded = true; + } + } + // One bounded round trip: it returns as soon as the server answers, so available + // output is forwarded immediately — the cadence only paces the idle rounds. + completed = process.poll(Duration.ofMillis(pollMillis)); + forward(process.stdout(), out); + forward(process.stderr(), err); + } + // Completion was observed with output possibly still buffered: drain it to the end. + forward(process.stdout(), out); + forward(process.stderr(), err); + return process.exitCode(); + } + + /** + * Forward the queued local input — at most {@link #MAX_INPUT_CHARS_PER_ROUND} characters per + * round — as one flushed write (one WSMan Send). A piece exceeding one round's budget is + * split: the surplus stays in {@code pendingInput} and leads the next round, so even a giant + * record keeps the rounds (and the output side) alternating. Once the local input ends and + * every pending character is out, the remote stdin is closed (the final Send carries + * {@code End="true"}) and {@code true} is returned: no further input will be forwarded. + */ + private static boolean forwardLocalInput( + final InputSource pieces, + final BufferedWriter stdin, + final OutgoingInput outgoing, + final String endOfInputCommand + ) throws IOException { + int budget = MAX_INPUT_CHARS_PER_ROUND; + boolean wrote = false; + while (budget > 0) { + if (outgoing.pending.length() > 0) { + final int take = Math.min(budget, outgoing.pending.length()); + final String slice = outgoing.pending.substring(0, take); + stdin.write(slice); + outgoing.pending.delete(0, take); + outgoing.atLineStart = LINE_SEPARATOR.endsWith(String.valueOf(slice.charAt(slice.length() - 1))); + budget -= take; + wrote = true; + continue; + } + final String piece = pieces.nextPiece(); + if (piece == null) { + break; + } + outgoing.pending.append(piece); + } + if (outgoing.pending.length() == 0 && pieces.endOfInput()) { + if (endOfInputCommand == null) { + // Closing flushes the written characters too: they travel with the End mark, one + // single Send. + stdin.close(); + } else { + // Console-mode stdin has no end of input: the remote command would wait forever + // for the next line, so the local EOF is translated into the command that ends it. + // A local input ending mid-line (no trailing newline) must get its line terminated + // first, or the exit would be glued onto it — "echo hiexit" runs, nothing exits, + // and an idle session never times out. + if (!outgoing.atLineStart) { + stdin.write(LINE_SEPARATOR); + } + stdin.write(endOfInputCommand); + stdin.write(LINE_SEPARATOR); + stdin.flush(); + } + return true; + } + if (wrote) { + stdin.flush(); + } + return false; + } + + /** What the pump still owes the remote stdin, plus whether it stopped on a line boundary. */ + private static final class OutgoingInput { + + /** The tail of a piece the current round could not fit entirely. */ + private final StringBuilder pending = new StringBuilder(); + + /** Whether the last character forwarded ended a line (true before anything is sent). */ + private boolean atLineStart = true; + } + + /** Write everything already buffered for the channel — never a protocol round trip. */ + private static void forward(final BufferedReader channel, final PrintStream target) throws IOException { + final char[] buffer = new char[4096]; + boolean wrote = false; + while (channel.ready()) { + final int read = channel.read(buffer); + if (read < 0) { + break; + } + target.print(new String(buffer, 0, read)); + wrote = true; + } + if (wrote) { + target.flush(); + } + } + + /** + * The production {@link InputSource}: a bounded queue fed by the helper thread reading the + * local standard input, drained by the pump without ever blocking. The reader normalizes + * every line ending (LF, CRLF, lone CR) to the CRLF the remote {@code cmd.exe} expects, and + * never materializes more than one bounded piece at a time — a record longer than + * {@link #MAX_QUEUED_PIECE_CHARS} is queued in slices, so memory stays bounded by characters, + * not by lines. + */ + static final class QueuedInputSource implements InputSource { + + /** + * One queued item: a piece of input, the normal end of the local input ({@code piece} and + * {@code failure} both null), or a local read failure to surface to the pump. + */ + private static final class Item { + + private final String piece; + private final IOException failure; + + private Item(final String piece, final IOException failure) { + this.piece = piece; + this.failure = failure; + } + } + + private final BlockingQueue queue = new LinkedBlockingQueue<>(INPUT_QUEUE_CAPACITY); + private boolean ended; + + /** + * Feed the queue from the local input, piece by piece, ending with the EOF marker — or + * with the read failure itself, so the pump fails the session instead of passing the + * truncated input off as the complete one. + */ + void readAll(final InputStream localInput) { + // The local input charset: what the terminal (or the redirection) actually feeds + // System.in with. The reader is deliberately never closed — the stream is the + // caller's (System.in). + final Reader reader = new BufferedReader( + new InputStreamReader(localInput, localInputCharset(System::getProperty, System.console())) + ); + final StringBuilder piece = new StringBuilder(); + try { + boolean skipLoneLineFeed = false; + int read; + while ((read = reader.read()) != -1) { + final char character = (char) read; + if (skipLoneLineFeed) { + skipLoneLineFeed = false; + if (character == '\n') { + // The LF of a CRLF pair: its CR already emitted the line ending. + continue; + } + } + if (character == '\n' || character == '\r') { + skipLoneLineFeed = character == '\r'; + piece.append("\r\n"); + put(piece); + } else { + piece.append(character); + if (piece.length() >= MAX_QUEUED_PIECE_CHARS) { + put(piece); + } + } + } + if (piece.length() > 0) { + put(piece); + } + queue.put(new Item(null, null)); + } catch (final IOException e) { + putSilently(new Item(null, e)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void put(final StringBuilder piece) throws InterruptedException { + queue.put(new Item(piece.toString(), null)); + piece.setLength(0); + } + + private void putSilently(final Item item) { + try { + queue.put(item); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public String nextPiece() throws IOException { + if (ended) { + return null; + } + final Item item = queue.poll(); + if (item == null) { + return null; + } + if (item.piece == null) { + ended = true; + if (item.failure != null) { + throw new IOException("Reading the local standard input failed", item.failure); + } + return null; + } + return item.piece; + } + + @Override + public boolean endOfInput() { + return ended; + } + } +} diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index de89e65..2883c51 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -21,21 +21,31 @@ */ import java.io.Console; +import java.io.FileDescriptor; +import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.io.PrintStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.net.ConnectException; import java.net.NoRouteToHostException; import java.net.SocketException; import java.net.UnknownHostException; +import java.nio.charset.Charset; import java.time.Duration; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.stream.Stream; import javax.net.ssl.SSLException; import org.metricshub.winrm.AuthScheme; +import org.metricshub.winrm.CommandRequest; +import org.metricshub.winrm.RemoteProcess; import org.metricshub.winrm.WinRMClient; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WqlRow; @@ -50,7 +60,10 @@ * WS-Enumeration pages arrive — a large query starts producing output immediately and memory * stays bounded, but a mid-stream failure can leave partial output on standard output (with a * nonzero exit code). Remote command output is forwarded live to the matching local output - * stream while the command runs. Diagnostics are written only to standard error. + * stream while the command runs; when the local standard input is not an interactive console + * (piped or redirected), it is forwarded as the remote command's standard input. The {@code shell} + * subcommand starts {@code cmd.exe} on the remote host and bridges it to the local terminal, + * line by line, until the remote shell exits. Diagnostics are written only to standard error. *

* NTLM is the default authentication scheme. Kerberos requires HTTPS. HTTPS validates certificates * and hostnames unless the explicitly insecure {@code --https-permissive} option is used. @@ -81,7 +94,56 @@ private WinRmCli() {} * @param arguments command-line arguments */ public static void main(final String[] arguments) { - System.exit(run(arguments, System.out, System.err, WinRmCli::connect)); + System.exit( + run( + arguments, + System.out, + System.err, + WinRmCli::connect, + WinRmCli::readConsolePassword, + new LocalInput(!detectRedirectedStdin(), System.in) + ) + ); + } + + /** + * Whether the local standard input looks piped or redirected — the signal engaging automatic + * stdin forwarding for the {@code command} subcommand. {@code System.console()} alone is not + * it: it is also null when only the output is redirected ({@code ... > result.txt}), + * and consuming an interactive terminal in that case would hang the CLI waiting for an EOF + * the user never sends. So the input itself is probed: a seekable descriptor is a redirected + * file (terminals and pipes are not seekable — this catches empty files and the null device + * too), and bytes already waiting can only come from a pipe or a redirection. The undetectable + * remainder — a pipe whose producer has not written anything by the time the CLI starts — is + * what the explicit {@code --stdin} option is for. + */ + private static boolean detectRedirectedStdin() { + if (System.console() != null) { + return false; + } + return stdinIsSeekable() || stdinHasAvailableInput(System.in); + } + + /** Bytes already waiting on the given (non-console) input: a pipe or a redirection. */ + static boolean stdinHasAvailableInput(final InputStream stdin) { + try { + return stdin.available() > 0; + } catch (final IOException e) { + return false; + } + } + + /** Whether descriptor 0 is seekable — a redirected file or the null device. */ + private static boolean stdinIsSeekable() { + // Deliberately never closed: closing a stream created on FileDescriptor.in closes the + // process's standard input itself. + final FileInputStream probe = new FileInputStream(FileDescriptor.in); + try { + probe.getChannel().position(); + return true; + } catch (final IOException e) { + return false; + } } static int run( @@ -99,6 +161,26 @@ static int run( final PrintStream standardError, final RemoteFactory remoteFactory, final PasswordReader passwordReader + ) { + // Tests reaching this overload behave as if run from an interactive console: no local + // standard input is forwarded implicitly (the test JVM's System.in is not theirs to consume). + return run( + arguments, + standardOutput, + standardError, + remoteFactory, + passwordReader, + new LocalInput(true, System.in) + ); + } + + static int run( + final String[] arguments, + final PrintStream standardOutput, + final PrintStream standardError, + final RemoteFactory remoteFactory, + final PasswordReader passwordReader, + final LocalInput localInput ) { try (CliArguments parsed = CliArguments.parse(arguments)) { if (parsed.operation() == CliArguments.Operation.HELP) { @@ -110,7 +192,7 @@ static int run( return 0; } ensurePassword(parsed, passwordReader); - return execute(parsed, standardOutput, standardError, remoteFactory); + return execute(parsed, standardOutput, standardError, remoteFactory, localInput); } catch (final CliUsageException e) { standardError.println("winrm-java: " + e.getMessage()); standardError.println("Try 'winrm-java --help' for usage."); @@ -144,7 +226,8 @@ private static int execute( final CliArguments arguments, final PrintStream standardOutput, final PrintStream standardError, - final RemoteFactory remoteFactory + final RemoteFactory remoteFactory, + final LocalInput localInput ) { final String previousKerberosKdc = System.getProperty(KERBEROS_KDC_PROPERTY); final String previousKerberosRealm = System.getProperty(KERBEROS_REALM_PROPERTY); @@ -164,10 +247,16 @@ private static int execute( ); return 0; } + if (arguments.operation() == CliArguments.Operation.SHELL) { + return interactiveShell(arguments, standardOutput, standardError, remote, localInput); + } // Forward each output chunk as it arrives, so a long-running command can be followed live. + // Piped/redirected local standard input travels the other way, as the command's stdin — + // automatically when detected, unconditionally with --stdin. final int exitCode = remote.executeCommand( arguments.input(), arguments.timeout(), + arguments.forwardStdin() || !localInput.terminal ? localInput.stream : null, chunk -> { standardOutput.print(chunk); standardOutput.flush(); @@ -192,7 +281,97 @@ private static int execute( } } + /** + * Run the interactive {@code shell} session: local Ctrl+C is rerouted to the remote child (a + * WSMan {@code ctrl_c} Signal) for the duration of the session, and the remote shell's exit + * code is propagated through the usual contract. + */ + private static int interactiveShell( + final CliArguments arguments, + final PrintStream standardOutput, + final PrintStream standardError, + final RemoteOperations remote, + final LocalInput localInput + ) throws Exception { + final AtomicBoolean interruptRequested = new AtomicBoolean(); + final Runnable restoreInterruptHandler = forwardSigint(interruptRequested); + try { + final int exitCode = remote.shell( + arguments.timeout(), + localInput.stream, + standardOutput, + standardError, + interruptRequested + ); + return remoteExitCode(exitCode, standardError); + } finally { + restoreInterruptHandler.run(); + } + } + + /** + * Reroute Ctrl+C (SIGINT) into the given flag instead of killing this JVM, so the interactive + * shell can forward it to the remote child process. Uses {@code sun.misc.Signal} reflectively: + * the JDK ships it in {@code jdk.unsupported}, but it is not part of the Java SE API, so a + * runtime without it simply keeps the default behavior (Ctrl+C ends the CLI — and the remote + * shell with it, through the client's close). Returns the action restoring the previous + * handler. + */ + private static Runnable forwardSigint(final AtomicBoolean interruptRequested) { + try { + final Class signalClass = Class.forName("sun.misc.Signal"); + final Class handlerClass = Class.forName("sun.misc.SignalHandler"); + final InvocationHandler invocationHandler = (proxy, method, args) -> { + if ("handle".equals(method.getName())) { + interruptRequested.set(true); + return null; + } + // Object methods a well-behaved proxy must answer (equals/hashCode/toString). + return invokeObjectMethod(proxy, method, args); + }; + final Object handler = Proxy.newProxyInstance( + handlerClass.getClassLoader(), + new Class[] + { handlerClass }, + invocationHandler + ); + final Object sigint = signalClass.getConstructor(String.class).newInstance("INT"); + final Method handle = signalClass.getMethod("handle", signalClass, handlerClass); + final Object previous = handle.invoke(null, sigint, handler); + return () -> { + try { + handle.invoke(null, sigint, previous); + } catch (final ReflectiveOperationException | RuntimeException ignored) { + // The session is over either way; the JVM exits right after. + } + }; + } catch (final ReflectiveOperationException | RuntimeException | LinkageError e) { + return () -> {}; + } + } + + /** Default implementations of the {@link Object} methods for a reflective proxy. */ + private static Object invokeObjectMethod(final Object proxy, final Method method, final Object[] args) { + switch (method.getName()) { + case "equals": + return proxy == args[0]; + case "hashCode": + return System.identityHashCode(proxy); + default: + return "winrm-java SIGINT forwarder"; + } + } + static RemoteOperations connect(final CliArguments arguments) { + return connect(arguments, 0); + } + + /** + * Connect, optionally pinning the remote command shell's console code page (0 keeps the + * default 65001). The interactive shell needs a single-byte code page — see + * {@link FluentRemoteOperations#shell}. + */ + static FluentRemoteOperations connect(final CliArguments arguments, final int consoleCodePage) { final WinRMClient.Builder builder = WinRMClient .builder(arguments.hostname()) .port(arguments.port()) @@ -206,6 +385,9 @@ static RemoteOperations connect(final CliArguments arguments) { // property, it does not leak to (or race with) anything else in the JVM. builder.trustAllCertificates(); } + if (consoleCodePage > 0) { + builder.consoleCodePage(consoleCodePage); + } final List authentications = arguments.authentications(); if (authentications != null && !authentications.isEmpty()) { builder.authentication( @@ -215,7 +397,7 @@ static RemoteOperations connect(final CliArguments arguments) { .toArray(AuthScheme[]::new) ); } - return new FluentRemoteOperations(builder.build()); + return new FluentRemoteOperations(builder.build(), codePage -> connect(arguments, codePage).client); } private static int remoteExitCode(final int exitCode, final PrintStream standardError) { @@ -304,6 +486,7 @@ private static String help() { return "Usage:\n" + " winrm-java [options] wql \n" + " winrm-java [options] command|cmd|exec|run \n" + + " winrm-java [options] shell\n" + "\n" + "Connection options:\n" + " -h, --hostname Target hostname or IP address (required)\n" + @@ -312,6 +495,7 @@ private static String help() { " -pf, --password-file Read a UTF-8 password from a file (preferred for automation)\n" + " -P, --port Target port (default: HTTP 5985, HTTPS 5986)\n" + " -t, --timeout Operation timeout in milliseconds (default: 60000)\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" + " --ntlm Use NTLM authentication (default)\n" + @@ -337,15 +521,40 @@ interface PasswordReader { char[] readPassword() throws CliUsageException; } + /** The local standard input: whether it is an interactive console, and the stream itself. */ + static final class LocalInput { + + private final boolean terminal; + private final InputStream stream; + + LocalInput(final boolean terminal, final InputStream stream) { + this.terminal = terminal; + this.stream = stream; + } + } + interface RemoteOperations extends AutoCloseable { /** Run the WQL query, handing each row to the consumer as it arrives. */ void streamWql(String query, long timeout, Consumer> rowConsumer) throws Exception; /** * Run the command, forwarding each decoded output chunk to the matching consumer as it - * arrives, and return the remote exit code. + * arrives, and return the remote exit code. A non-null {@code stdin} is consumed to its end + * and forwarded as the command's standard input. */ - int executeCommand(String command, long timeout, Consumer stdoutConsumer, Consumer stderrConsumer) + int executeCommand( + String command, + long timeout, + InputStream stdin, + Consumer stdoutConsumer, + Consumer stderrConsumer + ) throws Exception; + + /** + * Start {@code cmd.exe} on the remote host and bridge it to the given local streams until + * it exits; return its exit code. See {@link InteractiveShell}. + */ + int shell(long timeout, InputStream localInput, PrintStream out, PrintStream err, AtomicBoolean interruptRequested) throws Exception; @Override @@ -355,10 +564,35 @@ int executeCommand(String command, long timeout, Consumer stdoutConsumer /** The real remote operations: the streaming terminals of the fluent {@link WinRMClient}. */ static final class FluentRemoteOperations implements RemoteOperations { + /** + * What the {@code shell} subcommand runs remotely: {@code cmd.exe} with its command echo + * off ({@code /Q}), so a forwarded command line is never repeated in the output. + */ + static final String SHELL_COMMAND = "cmd.exe /Q"; + + /** + * The code page the interactive shell falls back to when the remote machine cannot be + * asked for its ANSI one: 1252, the most widespread Windows ANSI code page. ASCII — the + * whole command vocabulary — is identical in every ANSI code page, so only the non-ASCII + * characters of an unqueryable host are at risk. + */ + static final int DEFAULT_SHELL_CODE_PAGE = 1252; + + /** The UTF-8 code page: the client's default for commands, and unusable for a shell. */ + private static final int UTF8_CODE_PAGE = 65001; + private final WinRMClient client; + private final java.util.function.IntFunction clientFactory; FluentRemoteOperations(final WinRMClient client) { + this(client, codePage -> { + throw new UnsupportedOperationException("This operations handle cannot open a second connection."); + }); + } + + FluentRemoteOperations(final WinRMClient client, final java.util.function.IntFunction clientFactory) { this.client = client; + this.clientFactory = clientFactory; } @Override @@ -372,16 +606,147 @@ public void streamWql(final String query, final long timeout, final Consumer stdoutConsumer, final Consumer stderrConsumer ) { - return client + final CommandRequest request = client .command(command) .timeout(Duration.ofMillis(timeout)) .onStdout(stdoutConsumer) - .onStderr(stderrConsumer) - .execute() - .exitCode(); + .onStderr(stderrConsumer); + if (stdin != null) { + request.stdin(stdin); + } + return request.execute().exitCode(); + } + + @Override + public int shell( + final long timeout, + final InputStream localInput, + final PrintStream out, + final PrintStream err, + final AtomicBoolean interruptRequested + ) throws Exception { + // An interactive shell cannot run under console code page 65001, this client's default: + // a remote cmd.exe decodes the command lines it reads from its standard input ONE BYTE + // AT A TIME under that page, so every non-ASCII character becomes U+FFFD (measured on + // Windows Server 2008 R2 and 2022, in both stdin modes and for every input encoding). + // Any single-byte code page works, so the session runs under the remote machine's ANSI + // one and both directions use the matching charset. + // + // The code page is fixed when the shell is created, so it must be known BEFORE the + // first command: the probe runs on this connection (a WQL query creates no shell) and + // the session then opens its own connection with the page pinned. + final SessionEncoding encoding = sessionEncoding(remoteAnsiCodePage(timeout)); + try (WinRMClient shellClient = clientFactory.apply(encoding.codePage())) { + // cmd.exe /Q: no command echo — the local terminal already shows what the user + // types. Pipe-mode stdin (stdin()) makes the local end-of-input a real EOF, which + // cmd.exe exits on. + try ( + RemoteProcess process = shellClient + .command(SHELL_COMMAND) + .timeout(Duration.ofMillis(timeout)) + .charset(encoding.charset()) + .stdin() + .start()) { + return InteractiveShell.run( + process, + localInput, + out, + err, + interruptRequested, + InteractiveShell.DEFAULT_POLL_MILLIS, + null + ); + } + } + } + + /** + * The console code page an interactive session runs under, together with the charset that + * encodes and decodes it — resolved as ONE decision, so the shell can never be created + * under a page whose bytes the session would then misread. + */ + static final class SessionEncoding { + + private final int codePage; + private final Charset charset; + + SessionEncoding(final int codePage, final Charset charset) { + this.codePage = codePage; + this.charset = charset; + } + + int codePage() { + return codePage; + } + + Charset charset() { + return charset; + } + } + + /** + * Resolve the session's code page and charset from the code page the remote machine + * reports. Two answers are refused, and both fall back to {@link #DEFAULT_SHELL_CODE_PAGE} + * for BOTH the page and the charset: + *

    + *
  • 65001 — a host configured for UTF-8 reports it as its ANSI page, and 65001 is + * precisely the page an interactive {@code cmd.exe} cannot read command lines under;
  • + *
  • a page this JVM has no charset for — encoding the session with a charset that does + * not match the shell's page would corrupt both directions.
  • + *
+ * + * @param reportedCodePage the code page the remote machine reported + * @return the code page to pin and the charset to use with it, always consistent + */ + static SessionEncoding sessionEncoding(final int reportedCodePage) { + if (reportedCodePage != UTF8_CODE_PAGE) { + for (final String name : new String[] { + "windows-" + reportedCodePage, + "IBM" + reportedCodePage, + "x-IBM" + reportedCodePage, + "MS" + reportedCodePage + }) { + try { + return new SessionEncoding(reportedCodePage, Charset.forName(name)); + } catch (final RuntimeException ignored) { + // Try the next naming convention. + } + } + } + return new SessionEncoding(DEFAULT_SHELL_CODE_PAGE, Charset.forName("windows-" + DEFAULT_SHELL_CODE_PAGE)); + } + + /** + * The remote machine's ANSI code page. {@code Win32_OperatingSystem.CodeSet} reports exactly + * that value (it is the ANSI page — the very reason it is the wrong answer for decoding + * command OUTPUT, which follows the console page instead). A host that cannot answer falls + * back to {@link #DEFAULT_SHELL_CODE_PAGE}: an unusable answer must degrade non-ASCII + * input, never prevent the session from opening. + */ + private int remoteAnsiCodePage(final long timeout) { + try { + final List rows; + try ( + Stream stream = client + .wql("SELECT CodeSet FROM Win32_OperatingSystem") + .timeout(Duration.ofMillis(timeout)) + .stream()) { + rows = stream.collect(java.util.stream.Collectors.toList()); + } + if (!rows.isEmpty()) { + final String codeSet = rows.get(0).string("CodeSet"); + if (codeSet != null && !codeSet.trim().isEmpty()) { + return Integer.parseInt(codeSet.trim()); + } + } + } catch (final RuntimeException ignored) { + // No WMI access, or an answer that is not a code page number: fall back below. + } + return DEFAULT_SHELL_CODE_PAGE; } @Override diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index b120b22..3e0a734 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -29,7 +29,7 @@ /** * WS-Management SOAP envelope templates — the only "WSDL" the light client needs. * Covers Identify, WQL enumeration (Enumerate / Pull / Release), and the command shell lifecycle - * (Create / Command / Receive / Signal / Delete). + * (Create / Command / Send / Receive / Signal / Delete). */ final class Envelopes { @@ -48,12 +48,28 @@ final class Envelopes { private static final String ACTION_COMMAND = RSP + "/Command"; private static final String ACTION_RECEIVE = RSP + "/Receive"; private static final String ACTION_SIGNAL = RSP + "/Signal"; + private static final String ACTION_SEND = RSP + "/Send"; static final String SHELL_RESOURCE_URI = RSP + "/cmd"; static final String TERMINATE_CODE = RSP + "/signal/terminate"; + + /** + * The WSMan equivalent of a console Ctrl+C: it interrupts the command's child process without + * terminating the command (and its shell) the way {@link #TERMINATE_CODE} does. + */ + static final String CTRL_C_CODE = RSP + "/signal/ctrl_c"; + static final String COMMAND_STATE_DONE = RSP + "/CommandState/Done"; - private static final int MAX_ENVELOPE_SIZE = 153600; + static final int MAX_ENVELOPE_SIZE = 153600; + + /** + * Largest stdin payload carried by a single Send, in raw bytes before base64. The whole envelope + * must stay under {@link #MAX_ENVELOPE_SIZE}: base64 inflates by 4/3, and the SOAP header, + * selectors and element markup around the payload need a few hundred bytes — 96 KiB of raw input + * becomes 128 KiB of base64, leaving comfortable room inside the 150 KiB limit. + */ + static final int MAX_STDIN_CHUNK = 96 * 1024; /** * Console code page of the remote shell: UTF-8. The shell's output charset must be one the @@ -125,10 +141,22 @@ static String release(final String url, final String namespace, final String con // --- Command shell ----------------------------------------------------- - static String createShell(final String url, final String workingDirectory, final long timeoutMs) { + /** + * Create a command shell. + * + * @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 long timeoutMs, + final int codePage + ) { final String optionSet = "" + "TRUE" + - "" + CODEPAGE_UTF8 + "" + + "" + (codePage > 0 ? String.valueOf(codePage) : CODEPAGE_UTF8) + + "" + ""; final String workingDir = (workingDirectory == null || workingDirectory.trim().isEmpty()) ? "" @@ -142,9 +170,25 @@ static String createShell(final String url, final String workingDirectory, final ""; } - static String command(final String url, final String shellId, final String commandLine, final long timeoutMs) { + /** + * Start a command in an existing shell. + * + * @param consoleModeStdin value of the {@code WINRS_CONSOLEMODE_STDIN} option: {@code TRUE} makes + * the remote stdin behave like a console (what an interactive session and a command that + * never reads input want), {@code FALSE} makes it an ordinary pipe, which is what a + * command fed programmatic input needs — a console-mode stdin never reaches EOF for tools + * like {@code sort} or {@code findstr} + */ + static String command( + final String url, + final String shellId, + final String commandLine, + final long timeoutMs, + final boolean consoleModeStdin + ) { final String optionSet = "" + - "TRUE" + + "" + (consoleModeStdin ? "TRUE" : "FALSE") + + "" + "FALSE" + ""; return envelopeOpen(true) + @@ -154,6 +198,31 @@ static String command(final String url, final String shellId, final String comma ""; } + /** + * Feed one chunk of standard input to a running command. + * + * @param base64Data the chunk's bytes, base64-encoded (may be empty on a pure end-of-input Send) + * @param end {@code true} to mark this chunk as the last one — the remote stdin then reaches EOF + */ + static String send( + final String url, + final String shellId, + final String commandId, + final String base64Data, + final boolean end, + final long timeoutMs + ) { + return envelopeOpen(true) + + header(url, SHELL_RESOURCE_URI, ACTION_SEND, timeoutMs, shellSelector(shellId), null) + + "" + + base64Data + + ""; + } + static String receive(final String url, final String shellId, final String commandId, final long timeoutMs) { return envelopeOpen(true) + header(url, SHELL_RESOURCE_URI, ACTION_RECEIVE, timeoutMs, shellSelector(shellId), null) + @@ -162,13 +231,25 @@ static String receive(final String url, final String shellId, final String comma "\">stdout stderr"; } - static String signal(final String url, final String shellId, final String commandId, final long timeoutMs) { + /** + * Signal a running command. + * + * @param code the signal code — {@link #TERMINATE_CODE} to stop the command, {@link #CTRL_C_CODE} + * to interrupt its child process the way a console Ctrl+C would + */ + static String signal( + final String url, + final String shellId, + final String commandId, + final String code, + final long timeoutMs + ) { return envelopeOpen(true) + header(url, SHELL_RESOURCE_URI, ACTION_SIGNAL, timeoutMs, shellSelector(shellId), null) + "" + - TERMINATE_CODE + + code + ""; } diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 25894ac..1d1fead 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -112,6 +112,36 @@ public static LightWinRMService createInstance( final List authentications, final SSLContext sslContext, final boolean trustAllCertificates + ) throws WinRMException { + return createInstance(winRMEndpoint, timeout, ticketCache, authentications, sslContext, trustAllCertificates, 0); + } + + /** + * Create a light WinRM executor with an explicit console code page for the command shell. + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (used by the Kerberos scheme; {@code null} logs + * in with the password) + * @param authentications requested authentication schemes, tried in order (NTLM and/or Kerberos); + * {@code null}/empty means NTLM only + * @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname + * verification stays on); {@code null} uses the default configuration + * @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every + * server certificate and skip hostname verification — insecure, testing only + * @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001, + * which makes command output UTF-8 whatever the remote locale + * @return a new {@code LightWinRMService} + * @throws WinRMException on invalid arguments or an unsupported authentication request + */ + public static LightWinRMService createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final java.nio.file.Path ticketCache, + final List authentications, + final SSLContext sslContext, + final boolean trustAllCertificates, + final int consoleCodePage ) throws WinRMException { Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); @@ -148,7 +178,8 @@ public static LightWinRMService createInstance( sslSocketFactory, verifyHostname, authScheme, - winRMEndpoint.getRawUsername() + winRMEndpoint.getRawUsername(), + consoleCodePage ); return new LightWinRMService(winRMEndpoint, client); } @@ -287,6 +318,16 @@ private void checkWqlArguments( @Override public CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) throws TimeoutException, WindowsRemoteException { + return startCommand(command, workingDirectory, timeout, true); + } + + @Override + public CommandCursor startCommand( + final String command, + final String workingDirectory, + final long timeout, + final boolean consoleModeStdin + ) throws TimeoutException, WindowsRemoteException { checkNotClosed(); Utils.checkNonNull(command, "command"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); @@ -294,7 +335,7 @@ public CommandCursor startCommand(final String command, final String workingDire // 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) + () -> client.startCommand(command, workingDirectory, timeout, true, consoleModeStdin) ); return new CommandCursor() { @Override @@ -307,10 +348,32 @@ public Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRemo return adapt(callStreaming(() -> remoteCommand.pollChunk(maxWaitMillis))); } + @Override + public Chunk poll(final long askMillis, final long maxWaitMillis) + throws TimeoutException, WindowsRemoteException { + return adapt(callStreaming(() -> remoteCommand.pollChunk(askMillis, maxWaitMillis))); + } + private Chunk adapt(final WsmanClient.RemoteCommand.Chunk chunk) { return chunk == null ? null : new Chunk(chunk.stdout, chunk.stderr); } + @Override + public void send(final byte[] data, final boolean end) throws TimeoutException, WindowsRemoteException { + callStreaming(() -> { + remoteCommand.send(data, end); + return null; + }); + } + + @Override + public void interrupt() throws TimeoutException, WindowsRemoteException { + callStreaming(() -> { + remoteCommand.interrupt(); + return null; + }); + } + @Override public int exitCode() { return remoteCommand.exitCode(); diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 95c9c61..0fd96ca 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -26,6 +26,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; @@ -56,9 +57,16 @@ final class WsmanClient implements AutoCloseable { private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; private static final String FAULT_SHELL_NOT_FOUND = "2150858843"; - // A bounded poll shorter than this cannot be honored by a network round trip (the answer could - // not come back in time): it is waited out locally instead of going to the wire. - private static final long MIN_WIRE_POLL_MS = 100; + // The WSMan service clamps an OperationTimeout below 500 ms UP to 500 ms (MS-WSMV; measured on + // Windows Server 2008 R2): a bounded Receive's "nothing yet" fault never arrives before this + // floor, however early the header asks for it. + private static final long MIN_OPERATION_TIMEOUT_MS = 500; + + // A bounded poll shorter than this cannot be honored by a network round trip: the server + // answers no earlier than MIN_OPERATION_TIMEOUT_MS, and the fault needs transit slack to beat + // the socket cut at the budget. Shorter waits are waited out locally instead of going to the + // wire. + private static final long MIN_WIRE_POLL_MS = 750; // WS-Enumeration namespace: the EndOfSequence / EnumerationContext markers live here. Match them by // namespace, never by local name alone, so a WMI property that happens to be named "EndOfSequence" @@ -70,6 +78,7 @@ final class WsmanClient implements AutoCloseable { private static final String WSMAN_NS = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; private final long timeoutMs; + private final int consoleCodePage; private final String url; private final String rawUsername; private final AuthScheme auth; @@ -139,9 +148,11 @@ private static void checkNotCancelled() throws InterruptedException { final SSLSocketFactory sslSocketFactory, final boolean verifyHostname, final AuthScheme auth, - final String rawUsername + final String rawUsername, + final int consoleCodePage ) { this.timeoutMs = timeoutMs; + this.consoleCodePage = consoleCodePage; // A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext. this.url = (sslSocketFactory != null ? "https" : "http") + "://" + host + ":" + port + "/wsman"; this.rawUsername = rawUsername; @@ -428,7 +439,7 @@ CommandOutput executeCommand( 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)) { + try (RemoteCommand command = startCommand(commandLine, workingDirectory, operationTimeoutMs, false, true)) { RemoteCommand.Chunk chunk; while ((chunk = command.nextChunk()) != null) { stdout.write(chunk.stdout, 0, chunk.stdout.length); @@ -457,12 +468,15 @@ CommandOutput executeCommand( * fault or a socket read timeout into a {@link TimeoutException} instead of re-issuing * the Receive forever (the blocking path is bounded by the caller's wall-clock deadline * instead) + * @param consoleModeStdin value of the {@code WINRS_CONSOLEMODE_STDIN} option; see + * {@link Envelopes#command(String, String, String, long, boolean)} */ RemoteCommand startCommand( final String commandLine, final String workingDirectory, final long operationTimeoutMs, - final boolean failOnQuietTimeout + final boolean failOnQuietTimeout, + final boolean consoleModeStdin ) throws Exception { // Serialize the whole shell lifecycle (Create + Command + Receive loop + Signal) against any // other operation sharing this connection and the shellId field; see connectionPermit. @@ -482,7 +496,7 @@ RemoteCommand startCommand( checkNotCancelled(); String commandId; try { - commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout); + commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout, consoleModeStdin); } catch (final WinRMFaultException e) { if (!FAULT_SHELL_NOT_FOUND.equals(e.getFaultCode())) { throw e; @@ -493,7 +507,7 @@ RemoteCommand startCommand( shellId = null; createShell(shellWorkingDirectory, operationTimeoutMs, failOnQuietTimeout); checkNotCancelled(); - commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout); + commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout, consoleModeStdin); } opened = true; return new RemoteCommand(commandId, operationTimeoutMs, failOnQuietTimeout); @@ -534,6 +548,10 @@ final class Chunk { private Integer exitCode; private boolean finished; + // The end-of-input Send was delivered: the remote stdin reached EOF, later sends are a + // caller bug and are rejected locally instead of drawing a server fault. + private boolean stdinEnded; + private RemoteCommand(final String commandId, final long operationTimeoutMs, final boolean failOnQuietTimeout) { this.commandId = commandId; this.operationTimeoutMs = operationTimeoutMs; @@ -573,6 +591,21 @@ Chunk nextChunk() throws Exception { * timeout */ Chunk pollChunk(final long maxWaitMs) throws Exception { + return pollChunk(maxWaitMs, maxWaitMs); + } + + /** + * Cadence variant: ask the server to answer within {@code askMs} (the polling cadence), + * while allowing the answer itself up to {@code maxWaitMs} to arrive — a polling consumer + * (the interactive shell pump) wants short idle rounds without failing the session when a + * loaded or distant server takes longer than one cadence to get its answer across. The + * hard-bound variant above is simply {@code askMs == maxWaitMs}. + * + * @param askMs when the server should answer at the latest (its Receive hold) + * @param maxWaitMs how long to block at most, capped by the handle's own per-round-trip + * timeout + */ + Chunk pollChunk(final long askMs, final long maxWaitMs) throws Exception { if (finished) { return null; } @@ -582,24 +615,33 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { finishBounded(maxWaitMs); return null; } + // The per-round-trip timeout caps the poll, strictly: a bounded wait must never outlast + // the inactivity tolerance its caller configured. final long budget = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); if (budget < MIN_WIRE_POLL_MS) { - // No answer could come back in time: waiting the budget out locally is the only way - // to honor it. The protocol advances on the next full-size fetch or poll. + // Less than any network round trip can honor — because the caller asked for it, or + // because the handle's own per-round-trip timeout is below the protocol's floor. + // Waiting the budget out locally is the only way to honor it; the protocol advances + // on the next full-size poll or on an unbounded fetch, whose socket budget is the + // same per-round-trip timeout. Thread.sleep(budget); return new Chunk(new byte[0], new byte[0]); } - // Split the budget: the server may hold the Receive for the first part, and the rest is - // transit slack for its "nothing yet" op-timeout fault to arrive BEFORE the socket cuts - // at the full budget — the expected expiry of a bounded poll is that fault, and it must - // win the race or the poll would desync the connection it is supposed to leave intact. + // Split the budget: the server may hold the Receive for the first part (never more + // than the requested cadence), and the rest is transit slack for its "nothing yet" + // op-timeout fault to arrive BEFORE the socket cuts at the full budget — the expected + // expiry of a bounded poll is that fault, and it must win the race or the poll would + // desync the connection it is supposed to leave intact. The hold never asks for less + // than the service's own floor: the answer would not come any earlier, and the + // requested timeout should reflect when the server may answer. final long transit = Math.min(1_000, budget / 2); + final long hold = Math.max(MIN_OPERATION_TIMEOUT_MS, Math.min(askMs, budget - transit)); transport.pollTimeout(toSocketTimeoutMillis(budget)); try { checkNotCancelled(); final Decoded resp; try { - resp = request(Envelopes.receive(url, shellId, commandId, budget - transit)); + resp = request(Envelopes.receive(url, shellId, commandId, hold)); } catch (final SocketTimeoutException e) { // The peer answered neither within its shortened hold nor within the transit // slack. The Receive is abandoned mid-flight, so drop the connection outright: a @@ -621,6 +663,84 @@ Chunk pollChunk(final long maxWaitMs) throws Exception { } } + /** + * Feed standard input to the running command: one or more WSMan Send requests carrying the + * bytes as base64 {@code stdin} streams, the last one flagged {@code End} when {@code end} is + * set. Payloads larger than {@link Envelopes#MAX_STDIN_CHUNK} are split so no envelope + * exceeds the MaxEnvelopeSize the client advertises; the split happens on the ENCODED bytes, + * so a multibyte character straddling two Sends is reassembled by the remote pipe. + *

+ * A Send is an ordinary request under this handle's connection permit: it does not interleave + * with the Receive loop, it alternates with it on the caller's thread — the same discipline + * {@link java.lang.Process} pipes require. + * + * @param data the input bytes (may be empty, e.g. for a pure end-of-input Send) + * @param end whether this is the last input the command will get + */ + void send(final byte[] data, final boolean end) throws Exception { + if (finished || exitCode != null) { + throw new IllegalStateException("The command has completed: its standard input is closed."); + } + if (stdinEnded) { + throw new IllegalStateException("The command's standard input has already been closed."); + } + if (data.length == 0 && !end) { + // Nothing to say and no EOF to announce: an empty Send would be a pure round trip. + return; + } + int offset = 0; + do { + checkNotCancelled(); + final int length = Math.min(Envelopes.MAX_STDIN_CHUNK, data.length - offset); + final boolean last = offset + length >= data.length; + final String base64 = length == 0 + ? "" + : Base64.getEncoder().encodeToString(Arrays.copyOfRange(data, offset, offset + length)); + // The same streaming timeout translation as the Receive loop: a server staying + // quiet for a whole inactivity timeout is the documented TimeoutException, not a + // raw socket failure or fault. + exchange( + Envelopes.send(url, shellId, commandId, base64, end && last, operationTimeoutMs), + "Send", + operationTimeoutMs, + failOnQuietTimeout + ); + offset += length; + } while (offset < data.length); + if (end) { + stdinEnded = true; + } + } + + /** + * Interrupt the command's child process the way a console Ctrl+C would, WITHOUT terminating + * the command or its shell: the session stays usable afterward, which is what an interactive + * shell needs. A missing shell is tolerated, exactly like the terminate Signal. + */ + void interrupt() throws Exception { + // Nothing to interrupt once the command completed (or the handle was closed): the child + // is gone, and the Signal would only draw a fault. + if (finished || exitCode != null) { + return; + } + checkNotCancelled(); + try { + signal(commandId, Envelopes.CTRL_C_CODE, operationTimeoutMs); + } catch (final SocketTimeoutException e) { + // Same streaming translation as every other round trip: a server staying quiet + // for a whole inactivity timeout is the documented TimeoutException. + if (failOnQuietTimeout) { + throw quietTimeout("The interrupt Signal was not answered", operationTimeoutMs, e); + } + throw e; + } catch (final WinRMFaultException e) { + if (failOnQuietTimeout && FAULT_OPERATION_TIMEOUT.equals(e.getFaultCode())) { + throw quietTimeout("The interrupt Signal was not answered", operationTimeoutMs, null); + } + throw e; + } + } + /** Turn one 200 Receive response into a chunk, recording the exit code when it says Done. */ private Chunk toChunk(final Decoded resp) { final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); @@ -728,6 +848,7 @@ private void finishBounded(final long budgetMs) { * the completed command's state with the shell. */ private void terminateCompleted(final long budgetMs) { + // Same strict clamp as the bounded poll: the per-round-trip timeout caps this cleanup too. final long budget = Math.max(1, Math.min(budgetMs, operationTimeoutMs)); if (budget < MIN_WIRE_POLL_MS) { return; @@ -759,7 +880,7 @@ public void close() throws Exception { private void createShell(final String workingDirectory, final long timeoutMs, final boolean failOnQuietTimeout) throws Exception { final Document doc = exchange( - Envelopes.createShell(url, workingDirectory, timeoutMs), + Envelopes.createShell(url, workingDirectory, timeoutMs, consoleCodePage), "Create shell", timeoutMs, failOnQuietTimeout @@ -775,10 +896,14 @@ private void createShell(final String workingDirectory, final long timeoutMs, fi throw new IllegalStateException("Shell ID not found in Create response"); } - private String sendCommand(final String commandLine, final long timeoutMs, final boolean failOnQuietTimeout) - throws Exception { + private String sendCommand( + final String commandLine, + final long timeoutMs, + final boolean failOnQuietTimeout, + final boolean consoleModeStdin + ) throws Exception { final Document doc = exchange( - Envelopes.command(url, shellId, commandLine, timeoutMs), + Envelopes.command(url, shellId, commandLine, timeoutMs, consoleModeStdin), "Command", timeoutMs, failOnQuietTimeout @@ -791,7 +916,16 @@ private String sendCommand(final String commandLine, final long timeoutMs, final } private void terminate(final String commandId, final long timeoutMs) throws Exception { - final Decoded resp = request(Envelopes.signal(url, shellId, commandId, timeoutMs)); + signal(commandId, Envelopes.TERMINATE_CODE, timeoutMs); + } + + /** + * Send one Signal for a command. A missing shell is tolerated (the command may already be gone), + * and the cached shell id is then dropped so the next command creates a fresh one up front + * instead of discovering the stale one the hard way. + */ + private void signal(final String commandId, final String code, final long timeoutMs) throws Exception { + final Decoded resp = request(Envelopes.signal(url, shellId, commandId, code, timeoutMs)); // A missing shell is fine here — the command already finished and the shell may be gone. // But drop the cached ID so the next command creates a fresh shell up front instead of // discovering the stale one the hard way. diff --git a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java index ab9c975..b25682c 100644 --- a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java +++ b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java @@ -77,6 +77,34 @@ public static WindowsRemoteExecutor createInstance( final List authentications, final SSLContext sslContext, final boolean trustAllCertificates + ) throws WinRMException { + return createInstance(winRMEndpoint, timeout, ticketCache, authentications, sslContext, trustAllCertificates, 0); + } + + /** + * Create a {@link WindowsRemoteExecutor} with an explicit console code page for the command + * shell. + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (may be {@code null}) + * @param authentications requested authentication schemes (may be {@code null}) + * @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname + * verification stays on); {@code null} uses the default configuration + * @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every + * server certificate and skip hostname verification — insecure, testing only + * @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001 + * @return an executor backed by {@link LightWinRMService} + * @throws WinRMException for any problem creating the executor + */ + public static WindowsRemoteExecutor createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final Path ticketCache, + final List authentications, + final SSLContext sslContext, + final boolean trustAllCertificates, + final int consoleCodePage ) throws WinRMException { return LightWinRMService.createInstance( winRMEndpoint, @@ -84,7 +112,8 @@ public static WindowsRemoteExecutor createInstance( ticketCache, authentications, sslContext, - trustAllCertificates + trustAllCertificates, + consoleCodePage ); } } diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 0d5b725..ad3b3f0 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -1,5 +1,5 @@ -keywords: cli, command line, standalone, jar, wql, exec, exit codes, manual -description: Manual page of the winrm-java standalone command-line client - subcommands, options, passwords, Kerberos, streaming output, and exit codes. +keywords: cli, command line, standalone, jar, wql, exec, shell, interactive, stdin, exit codes, manual +description: Manual page of the winrm-java standalone command-line client - subcommands, options, passwords, Kerberos, streaming output, the interactive shell, and exit codes. # Command-Line Client @@ -15,6 +15,7 @@ This page is its manual. ```text java -jar winrm-java-standalone.jar [options] wql java -jar winrm-java-standalone.jar [options] command|cmd|exec|run +java -jar winrm-java-standalone.jar [options] shell java -jar winrm-java-standalone.jar --help | --version ``` @@ -24,9 +25,11 @@ java -jar winrm-java-standalone.jar --help | --version | --- | --- | | `wql ` | Run a WQL query and print the rows to stdout as UTF-8 [JSON Lines](https://jsonlines.org/). | | `command ` | Run a command on the remote host, forwarding its output. `cmd`, `exec`, and `run` are aliases. | +| `shell` | Open an interactive `cmd.exe` session on the remote host (see [Interactive shell](#Interactive_shell)). | Everything after the subcommand is the query or the command line; quoting follows your local -shell's rules, and multi-word command lines are reassembled for the remote `cmd.exe`. +shell's rules, and multi-word command lines are reassembled for the remote `cmd.exe`. `shell` +takes no argument. ## Options @@ -38,6 +41,7 @@ shell's rules, and multi-word command lines are reassembled for the remote `cmd. | `-pf, --password-file ` | Read the password from a UTF-8 file (preferred for automation, see below). | | `-P, --port ` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. | | `-t, --timeout ` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#Timeout_semantics). | +| `-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`. | | `--ntlm` | Authenticate with NTLM (the default). | @@ -93,6 +97,61 @@ real time. The output is decoded as UTF-8: the remote shell is created with cons locale. See [Character encoding](commands.html#character-encoding) for the two legacy tools that ignore the console code page. +When the local standard input is piped or redirected, it is forwarded as the remote command's +standard input, with pipe semantics, so filters just work: + +```bash +java -jar winrm-java-standalone.jar -h server -u 'DOMAIN\user' -pf pw.txt command sort < data.txt +``` + +Forwarding engages automatically when the standard input is detectably redirected: it is a +seekable file (any `< file` redirection, including an empty file or the null device), or bytes +are already waiting on it at startup (a normal pipe). An interactive terminal is never consumed, +even when only the *output* is redirected (`... command hostname > result.txt`). The one +undetectable case is a pipe whose producer has written nothing by the time the CLI starts: pass +`-i`/`--stdin` to force forwarding there. The input is delivered in full before the output is +read: piping a large input into a command that floods its output at the same time can deadlock +both sides (the classic pipe deadlock), exactly as with `java.lang.Process`. + +## Interactive shell + +```bash +java -jar winrm-java-standalone.jar -h server.example.net -u 'DOMAIN\user' -pf pw.txt shell +``` + +`shell` starts `cmd.exe` on the remote host and bridges it to the local terminal until the remote +shell exits (type `exit`, or send end-of-input — Ctrl+Z then Enter on Windows, Ctrl+D elsewhere — +which the session turns into an `exit`). The remote exit code is propagated through the usual +[exit-code contract](#Exit_codes). + +* **Echo is off** — the remote shell runs `cmd.exe /Q`, so the input you forward is never + repeated back: your terminal already shows what you type, and the output stream carries the + prompts and the command output only. +* **The session runs under a single-byte code page**, the remote machine's ANSI one (queried + once per session from `Win32_OperatingSystem.CodeSet`, falling back to 1252 when the host + cannot answer); both what you type and what you see use it. This is deliberate: a remote + `cmd.exe` decodes the command lines it reads from its standard input **one byte at a time** + under code page 65001, so every non-ASCII character would be lost. `winrs` has the same + constraint. The practical limit is that characters outside the host's ANSI code page cannot be + typed or displayed in an interactive session — `é` on a Western-European host is fine. + The other subcommands are unaffected: `wql` and `command` keep code page 65001 and full UTF-8 + output, and piped input to `command` transfers bytes unconverted. +* **Line-oriented, like `winrs`** — input is line-buffered by the local terminal and forwarded + when you press Enter. There is no raw-terminal/PTY mode (with zero dependencies there is none in + pure Java): full-screen programs, cmd.exe line editing, tab completion, and ANSI cursor control + are not supported. Command output echoes back with sub-second latency; a line typed while the + session is idle can wait up to the poll cadence (about one second — the WSMan protocol's floor + for a bounded poll) before it is forwarded. +* **Ctrl+C interrupts the remote command, not the session** — it is forwarded as the WSMan + `ctrl_c` signal, which stops the running remote child (like a console Ctrl+C) and returns to the + remote prompt. On a Java runtime without `sun.misc.Signal`, Ctrl+C keeps its default behavior + and ends the CLI (terminating the remote shell with it). +* **An idle session does not time out** — `-t`/`--timeout` bounds each protocol round trip, and + every round trip of an idle session completes with the protocol's "nothing yet" answer. Only a + server that stops answering altogether trips the timeout. A `--timeout` below 1000 ms is + rejected for `shell`: one poll round trip cannot complete faster (the WSMan service holds a + bounded request for at least 500 ms before answering "nothing yet"). + ## Timeout semantics `-t`/`--timeout` follows the operation: @@ -102,6 +161,8 @@ ignore the console code page. the server keeps answering. * For `command`, it is the **overall deadline** covering the command itself and any file uploads. +* For `shell`, it bounds **each protocol round trip**; an idle interactive session never trips it + (see [Interactive shell](#Interactive_shell)). See [Timeouts and Errors](timeouts-and-errors.html) for the underlying semantics. diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 75f284e..8139bc5 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -1,5 +1,5 @@ -keywords: command, execute, cmd, stdout, stderr, exit code, file copy, script -description: Execute remote commands with the fluent WinRMClient API, capture output and exit codes, and copy local files to the host. +keywords: command, execute, cmd, 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 @@ -47,6 +47,9 @@ Everything between `command(...)` and `execute()` is optional: | `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. | | `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). | +| `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). | ## The result @@ -93,6 +96,91 @@ Points to know: keeps producing output. Use `waitFor(Duration)` when you need a hard deadline. See [Timeouts and Errors](timeouts-and-errors.html). +## Standard input + +Commands that read their standard input can be fed in two ways. + +### Pre-supplied input + +`stdin(...)` on the request delivers the whole input right after the command starts, ending with +the protocol's end-of-input mark so the remote stdin reaches EOF — the remote equivalent of a +local `< file` redirection. It works with both `execute()` and `start()`: + +```java +CommandResult result = client.command("sort") + .stdin(Path.of("data.txt")) // also stdin(InputStream) and stdin(String) + .execute(); +``` + +`stdin(String)` is encoded with the request's charset (see `charset(...)`); the `Path` and +`InputStream` variants send the bytes exactly as stored. Large input is split into +protocol-sized chunks automatically. + +Supplying input switches the remote stdin to **pipe semantics** +(`WINRS_CONSOLEMODE_STDIN=FALSE`): filters like `sort`, `findstr`, or `more` consume it and +terminate on EOF, exactly as with a local redirection. Without it, the historical console +semantics are kept. + +### Interactive input + +For a request started with `start()`, `RemoteProcess.stdin()` completes the `java.lang.Process` +shape: written text is buffered locally, `flush()` carries it to the host (one WSMan `Send`), +and `close()` marks the end of input. Declare the interactive input with the no-argument +`stdin()` on the request — it switches the remote stdin to pipe semantics, so closing the writer +actually delivers EOF: + +```java +try (RemoteProcess p = client.command("some-repl.exe").stdin().start()) { + try (BufferedWriter in = p.stdin()) { + in.write("first request\n"); + in.flush(); // delivers the buffered text + System.out.println(p.stdout().readLine()); + } // close() = end of input (EOF) + p.waitFor(); +} +``` + +Without the declaration, the remote stdin keeps the historical console semantics: writes are +still delivered, but a filter waiting for its input to *end* (`sort`, `findstr`, `more`) never +sees the EOF. Text is encoded statefully across flushes — a charset mark is emitted once and a +surrogate pair split by a flush is completed by the next write, exactly as one whole-string +encode. + +Writes and reads alternate on the caller's thread, exactly like `java.lang.Process` pipes — +including the classic deadlock, which is the caller's to avoid: blocking on a read while the +remote command itself is blocked waiting for input (or feeding a large input to a command that +floods its output in the meantime) hangs both sides until the inactivity timeout fires. + +`RemoteProcess` also exposes `interrupt()` — the WSMan `ctrl_c` Signal, the remote equivalent of +a console Ctrl+C: it interrupts the command's child process without terminating the command or +the process handle. + +### Input encoding + +Input encoding is not symmetric with output encoding, because Windows treats the two directions +differently: + +* **Pipe semantics** (any `stdin(...)`, including the no-argument form) — the bytes reach the + process **unconverted**. They are encoded with the request's charset (UTF-8 by default), which + is what a program reading a UTF-8 stream expects, and `stdin(Path)`/`stdin(InputStream)` send + the bytes verbatim. +* **Console semantics** (no `stdin` declaration — a command started with `start()` and written + to through `RemoteProcess.stdin()`) — the WinRM service converts the bytes to console input + itself, using a code page that depends on the Windows version. Prefer pipe semantics, or set + `stdinCharset(...)` to match the session's console code page. + +Output is unaffected either way: it follows the shell's console code page, which this client +pins to UTF-8. + +> A remote `cmd.exe` reading its **command lines** from standard input cannot handle non-ASCII +> at all under console code page 65001 — Windows decodes that input one byte at a time, turning +> every non-ASCII byte into `U+FFFD`, whatever the encoding used to send it. An interactive +> session must therefore run under a single-byte console code page: build the client with +> `consoleCodePage(...)` (the remote machine's ANSI page) and use the matching charset in both +> directions, as the CLI's `shell` subcommand does. Data piped to an ordinary program (`sort`, +> `findstr`, your own executable) is not affected: it never goes through cmd's parser, so the +> default code page 65001 and UTF-8 are right for it. + ### Tailing the output of a blocking execution When you only want to *observe* the output live — logging, progress reporting — but still want the diff --git a/src/test/java/org/metricshub/winrm/CommandStdinTest.java b/src/test/java/org/metricshub/winrm/CommandStdinTest.java new file mode 100644 index 0000000..264ac7e --- /dev/null +++ b/src/test/java/org/metricshub/winrm/CommandStdinTest.java @@ -0,0 +1,558 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.metricshub.winrm.light.FakeWsmanResponses.commandResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.done; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; +import static org.metricshub.winrm.light.FakeWsmanResponses.fault; +import static org.metricshub.winrm.light.FakeWsmanResponses.envelope; +import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated; +import static org.metricshub.winrm.light.FakeWsmanResponses.sendResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.stream; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +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.WinRMClientException; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.light.FakeWsmanServer; + +/** + * End-to-end tests of command standard input (issue #136, phase 1) against + * {@link FakeWsmanServer}: pre-supplied input on the builders ({@code stdin(...)}), the + * process-style {@code RemoteProcess.stdin()} writer, the {@code ctrl_c} interrupt, the + * console-mode option on the wire, chunking, the {@code End} flag, and the cleanup discipline + * (early close, stdin after completion). + */ +class CommandStdinTest { + + private static final String DOMAIN = "FAKE"; + private static final String USER = "user"; + private static final String PASSWORD = "s3cret-Passw0rd"; + + private static final String SHELL_ID = "SHELL-1"; + private static final String COMMAND_ID = "CMD-1"; + + private FakeWsmanServer server; + + @BeforeEach + void startServer() throws Exception { + server = new FakeWsmanServer(DOMAIN, USER, PASSWORD); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private WinRMClient.Builder builder() { + return WinRMClient + .builder("127.0.0.1") + .port(server.port()) + .credentials(DOMAIN + "\\" + USER, PASSWORD.toCharArray()) + .timeout(Duration.ofSeconds(10)); + } + + private void enqueueStartup() { + server.enqueue(200, envelope(resourceCreated(SHELL_ID))).enqueue(200, envelope(commandResponse(COMMAND_ID))); + } + + @Test + void presuppliedStringStdinIsDeliveredWithPipeSemantics() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "alpha\r\nbeta\r\n".getBytes(StandardCharsets.UTF_8)), + done(COMMAND_ID, 0) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + final CommandResult result = client.command("sort").stdin("beta\nalpha\n").execute(); + + assertEquals(0, result.exitCode()); + assertEquals("alpha\r\nbeta\r\n", result.stdout()); + } + + // The whole input fits one Send: a single chunk carrying the End flag. + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("beta\nalpha\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertTrue(chunks.get(0).end()); + + // Programmatic stdin switches the remote stdin to pipe semantics on the wire. + final List requests = server.decryptedRequests(); + assertTrue( + requests.get(1).contains("FALSE"), + requests.get(1) + ); + } + + @Test + void presuppliedFileStdinIsDeliveredVerbatim(@TempDir final Path tempDir) throws Exception { + final byte[] content = "line one\r\nline two\r\n".getBytes(StandardCharsets.UTF_8); + final Path file = tempDir.resolve("input.txt"); + Files.write(file, content); + + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 3)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + assertEquals(3, client.command("findstr two").stdin(file).execute().exitCode()); + } + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals(content, chunks.get(0).data()); + assertTrue(chunks.get(0).end()); + } + + @Test + void largePresuppliedStdinIsChunkedAndOnlyTheLastChunkCarriesEnd() throws Exception { + // Three 64 KiB read buffers: 64 KiB + 64 KiB + 18 928 bytes. + final int size = 150_000; + final StringBuilder text = new StringBuilder(size); + for (int i = 0; i < size; i++) { + text.append((char) ('a' + i % 26)); + } + + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + assertEquals(0, client.command("sort").stdin(text.toString()).execute().exitCode()); + } + + final List chunks = server.stdinChunks(); + assertEquals(3, chunks.size()); + assertFalse(chunks.get(0).end()); + assertFalse(chunks.get(1).end()); + assertTrue(chunks.get(2).end()); + + // Reassembling the chunks yields exactly the input. + final ByteArrayOutputStream reassembled = new ByteArrayOutputStream(); + for (final FakeWsmanServer.StdinChunk chunk : chunks) { + reassembled.writeBytes(chunk.data()); + } + assertArrayEquals(text.toString().getBytes(StandardCharsets.UTF_8), reassembled.toByteArray()); + } + + @Test + void emptyStdinAnnouncesOnlyTheEndOfInput() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + assertEquals(0, client.command("sort").stdin("").execute().exitCode()); + } + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertEquals(0, chunks.get(0).data().length); + assertTrue(chunks.get(0).end()); + } + + @Test + void presuppliedStdinWithStartClosesTheWriterFromTheStart() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("sort").stdin("beta\nalpha\n").start()) { + // The input was delivered in full at startup: the writer rejects further input + // immediately, instead of silently buffering it into the void. + assertThrows(IOException.class, () -> process.stdin().write("more")); + assertEquals(0, process.waitFor()); + } + } + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("beta\nalpha\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertTrue(chunks.get(0).end()); + } + + @Test + void aSendLeftUnansweredSurfacesAsTheDocumentedTimeout() throws Exception { + enqueueStartup(); + // The Send stays unanswered past the inactivity timeout: the documented timeout must + // surface (the CLI maps it to exit 124), not a generic connection or protocol failure. + server.enqueueDelayed(200, envelope(sendResponse()), 4_000); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(1_500)).build()) { + final RemoteProcess process = client.command("repl.exe").stdin().start(); + final BufferedWriter stdin = process.stdin(); + stdin.write("ping\n"); + assertThrows(WinRMTimeoutException.class, stdin::flush); + } + } + + @Test + void aFailedStdinDeliveryIsNotMaskedByACleanupFailure(@TempDir final Path tempDir) { + enqueueStartup(); + // The terminate Signal cleaning up the failed start is itself answered with a fault: the + // original delivery failure must win, with the cleanup failure attached as suppressed. + server.enqueue(500, fault("999", "Signal rejected")); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + final Path missing = tempDir.resolve("missing.txt"); + final WinRMClientException failure = assertThrows( + WinRMClientException.class, + () -> client.command("sort").stdin(missing).start() + ); + assertTrue(failure.getCause() instanceof java.nio.file.NoSuchFileException, String.valueOf(failure.getCause())); + assertEquals(1, failure.getCause().getSuppressed().length); + } + } + + @Test + void remoteProcessStdinSupportsAWriteFlushReadRoundTrip() throws Exception { + enqueueStartup(); + server + // write + flush → one Send + .enqueue(200, envelope(sendResponse())) + // the REPL answers + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", COMMAND_ID, "pong\r\n".getBytes(StandardCharsets.UTF_8)), null)) + ) + // closing stdin → the End-of-input Send + .enqueue(200, envelope(sendResponse())) + // the REPL exits + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("repl.exe").stdin().start()) { + final BufferedWriter stdin = process.stdin(); + final BufferedReader stdout = process.stdout(); + + stdin.write("ping\n"); + stdin.flush(); + assertEquals("pong", stdout.readLine()); + + stdin.close(); + assertEquals(0, process.waitFor()); + } + } + + final List chunks = server.stdinChunks(); + assertEquals(2, chunks.size()); + assertArrayEquals("ping\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertFalse(chunks.get(0).end()); + assertEquals(0, chunks.get(1).data().length); + assertTrue(chunks.get(1).end()); + + // The no-argument stdin() declared interactive input: pipe semantics on the wire, so the + // End mark above actually delivered EOF to the command. + assertTrue( + server.decryptedRequests().get(1).contains("FALSE") + ); + } + + @Test + void interactiveWriteLargerThanOneEnvelopeIsSplit() throws Exception { + // 200 KiB of ASCII: 98 304 + 98 304 + 8 192 bytes across three Sends of one flush. + final char[] text = new char[200 * 1024]; + java.util.Arrays.fill(text, 'x'); + + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("consume.exe").start()) { + process.stdin().write(text); + process.stdin().flush(); + } + } + + final List chunks = server.stdinChunks(); + assertEquals(3, chunks.size()); + assertEquals(96 * 1024, chunks.get(0).data().length); + assertEquals(96 * 1024, chunks.get(1).data().length); + assertEquals(200 * 1024 - 2 * 96 * 1024, chunks.get(2).data().length); + assertFalse(chunks.get(0).end()); + assertFalse(chunks.get(1).end()); + assertFalse(chunks.get(2).end()); + } + + @Test + void earlyCloseWithStdinOpenLeavesNoStrayRequests() throws Exception { + enqueueStartup(); + server.enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("repl.exe").start(); + final BufferedWriter stdin = process.stdin(); + stdin.write("never flushed"); + process.close(); + + final int requestsAfterClose = server.decryptedRequests().size(); + assertEquals(3, requestsAfterClose, () -> String.join("\n---\n", server.decryptedRequests())); + assertTrue(server.decryptedRequests().get(2).contains("signal/terminate")); + + // Ending the input after the close is silent cleanup: no request may leave the client, + // and the input it still held is discarded, like a java.lang.Process pipe's. + stdin.close(); + assertEquals(requestsAfterClose, server.decryptedRequests().size()); + + // Further writes are refused locally. + assertThrows(IOException.class, () -> writeAndFlush(stdin, "more")); + assertEquals(requestsAfterClose, server.decryptedRequests().size()); + } + } + + @Test + void stdinAfterCompletionIsSilentOnCloseAndRefusedOnWrite() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 5)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("run.exe").start()) { + assertEquals(5, process.waitFor()); + final int requestsAfterCompletion = server.decryptedRequests().size(); + + // Closing stdin after completion is a clean no-op... + process.stdin().close(); + assertEquals(requestsAfterCompletion, server.decryptedRequests().size()); + + // ...but writing to it is refused, locally. + assertThrows(IOException.class, () -> writeAndFlush(process.stdin(), "late")); + assertEquals(requestsAfterCompletion, server.decryptedRequests().size()); + } + } + } + + @Test + void interruptSendsCtrlCAndTheSessionContinues() throws Exception { + enqueueStartup(); + server + // the ctrl_c Signal + .enqueue(200, envelope(signalResponse())) + // the interrupted child's shell keeps running, then exits + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 130)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("cmd.exe").start()) { + process.interrupt(); + assertEquals(130, process.waitFor()); + + // Once completed, further interrupts are local no-ops. + final int requests = server.decryptedRequests().size(); + process.interrupt(); + assertEquals(requests, server.decryptedRequests().size()); + } + } + + final List requests = server.decryptedRequests(); + assertTrue(requests.get(2).contains("signal/ctrl_c"), requests.get(2)); + assertTrue(requests.get(4).contains("signal/terminate"), requests.get(4)); + + // Without any stdin declaration the historical console semantics are kept. + assertTrue( + requests.get(1).contains("TRUE"), + requests.get(1) + ); + } + + @Test + void stdinEncodingIsStatefulAcrossFlushes() throws Exception { + // U+1F600 as its surrogate halves: the pair is split across two flushes below. + final char high = (char) 0xD83D; + final char low = (char) 0xDE00; + final String emoji = new String(new char[] { high, low }); + + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try ( + RemoteProcess process = client.command("consume.exe") + .charset(StandardCharsets.UTF_16) + .stdin() + .start()) { + final BufferedWriter stdin = process.stdin(); + // Two flushes of a charset with a byte-order mark, the second one ending on a lone + // high surrogate: incremental encoding must yield exactly one whole-string encode. + stdin.write("ab"); + stdin.flush(); + stdin.write("cd"); + stdin.write(high); + stdin.flush(); + stdin.write(low); + stdin.close(); + } + } + + final List chunks = server.stdinChunks(); + assertEquals(3, chunks.size()); + final ByteArrayOutputStream reassembled = new ByteArrayOutputStream(); + for (final FakeWsmanServer.StdinChunk chunk : chunks) { + reassembled.writeBytes(chunk.data()); + } + assertArrayEquals(("abcd" + emoji).getBytes(StandardCharsets.UTF_16), reassembled.toByteArray()); + // The byte-order mark travels once, with the first chunk only. + assertArrayEquals("ab".getBytes(StandardCharsets.UTF_16), chunks.get(0).data()); + // The half pair was withheld at its flush and completed by the final write: the second + // chunk carries "cd" alone, the last one the whole character. + assertArrayEquals("cd".getBytes(StandardCharsets.UTF_16BE), chunks.get(1).data()); + assertArrayEquals(emoji.getBytes(StandardCharsets.UTF_16BE), chunks.get(2).data()); + assertTrue(chunks.get(2).end()); + } + + @Test + void aDecodeOnlyCharsetDoesNotBreakAProcessThatNeverWrites() throws Exception { + // x-JISAutoDetect decodes but cannot encode (Charset.newEncoder() throws): it must only + // ever fail a caller actually WRITING input, never a process that just reads output. + org.junit.jupiter.api.Assumptions.assumeTrue(java.nio.charset.Charset.isSupported("x-JISAutoDetect")); + + enqueueStartup(); + server + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "plain ascii\r\n".getBytes(StandardCharsets.US_ASCII)), + done(COMMAND_ID, 0) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try ( + RemoteProcess process = client.command("run.exe") + .charset(java.nio.charset.Charset.forName("x-JISAutoDetect")) + .start()) { + assertEquals("plain ascii", process.stdout().readLine()); + assertEquals(0, process.waitFor()); + + // Writing, on the other hand, has no encoder to work with: the failure is the + // writer's, reported on the flush that actually needs it. + process.stdin().write("input"); + assertThrows(UnsupportedOperationException.class, () -> process.stdin().flush()); + } + } + } + + @Test + void cursorRejectsInputAfterTheEndMark() throws Exception { + enqueueStartup(); + server.enqueue(200, envelope(sendResponse())).enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("sort", null, 10_000, false)) { + cursor.send("all of it".getBytes(StandardCharsets.UTF_8), true); + final int requestsAfterEnd = server.decryptedRequests().size(); + + // The remote stdin reached EOF: later input is a caller bug, rejected locally. + assertThrows( + IllegalStateException.class, + () -> cursor.send("too late".getBytes(StandardCharsets.UTF_8), false) + ); + assertEquals(requestsAfterEnd, server.decryptedRequests().size()); + } + } + } + + @Test + void interactiveStdinDeclarationIsRejectedByExecute() { + try (WinRMClient client = builder().build()) { + // execute() cannot take interactive input: the misconfiguration is rejected before any + // request leaves the client — a command waiting on a never-fed pipe would hang instead. + assertThrows(IllegalStateException.class, () -> client.command("sort").stdin().execute()); + + // The last stdin call wins: the no-argument declaration discards previously + // pre-supplied input, so this request is interactive too — and rejected the same way. + assertThrows(IllegalStateException.class, () -> client.command("sort").stdin("data\n").stdin().execute()); + } + assertEquals(0, server.decryptedRequests().size()); + } + + /** Write then flush, unwrapping nothing: the assertion targets the raised exception type. */ + private static void writeAndFlush(final BufferedWriter writer, final String text) throws IOException { + writer.write(text); + writer.flush(); + } +} diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index dd07b45..a9fbb89 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -519,14 +519,15 @@ void deadPeerCannotHoldABoundedWaitHostage() throws Exception { try (WinRMClient client = builder().build()) { try (RemoteProcess process = client.command("dead.exe").charset(StandardCharsets.UTF_8).start()) { final long start = System.nanoTime(); - assertThrows(WinRMTimeoutException.class, () -> process.waitFor(Duration.ofMillis(200))); + assertThrows(WinRMTimeoutException.class, () -> process.waitFor(Duration.ofMillis(1_000))); final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; assertTrue( elapsedMillis < 3_000, "a dead peer must be detected at the bounded wait, not " + elapsedMillis + " ms later" ); - // The server was asked to answer within half the 200 ms budget. - assertTrue(server.decryptedRequests().get(2).contains("PT0.1S<")); + // The server was asked to answer at the WSMan floor (the budget minus the transit + // slack, never below 500 ms — the service would not answer earlier anyway). + assertTrue(server.decryptedRequests().get(2).contains("PT0.5S<")); } // close() terminated the command over a fresh connection (the abandoned one was dropped). final List requests = server.decryptedRequests(); @@ -560,17 +561,62 @@ void completionSignalIsBoundedByThePollBudget() throws Exception { } } + @Test + void aPollAnswerSlowerThanTheCadenceDoesNotFailTheStream() throws Exception { + enqueueCommandStartup(); + server + // The answer takes clearly longer than the 1 s polling cadence — a loaded server — + // but stays well within the configured inactivity timeout: the poll must wait for it + // rather than cutting the connection at the cadence. + .enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("late but fine\n"), done(COMMAND_ID, 6))), 2_500) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("busy.exe").charset(StandardCharsets.UTF_8).start()) { + assertFalse(process.poll(Duration.ofSeconds(1)), "the Done chunk itself is not completion yet"); + assertTrue(process.poll(Duration.ofSeconds(1)), "completion must be observable"); + assertEquals(6, process.exitCode()); + assertEquals("late but fine", process.stdout().readLine()); + } + } + } + + @Test + void aSubFloorInactivityTimeoutKeepsBoundedPollsLocalAndTheProcessUsable() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 4)))) + .enqueue(200, envelope(signalResponse())); + + // The per-round-trip timeout caps a bounded poll STRICTLY: a 500 ms inactivity timeout + // bounds the poll to 500 ms however long the caller's wait, and 500 ms is below what a + // round trip can honor (the server holds a bounded Receive for at least 500 ms, plus + // transit). Such a poll is therefore waited out locally, touching nothing... + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + try (RemoteProcess process = client.command("run.exe").charset(StandardCharsets.UTF_8).start()) { + final int requestsBefore = server.decryptedRequests().size(); + assertFalse(process.poll(Duration.ofSeconds(5)), "a sub-floor budget cannot observe completion"); + assertEquals(requestsBefore, server.decryptedRequests().size(), "no request may reach the wire"); + + // ...and the process stays fully usable: the unbounded path, whose socket budget is + // that same per-round-trip timeout, observes the completion. + assertEquals(4, process.waitFor()); + assertEquals("done", process.stdout().readLine()); + } + } + } + @Test void completionArrivingNearTheDeadlineIsStillReported() throws Exception { enqueueCommandStartup(); // The final Done-carrying response lands close to the wait's deadline: too little budget is // left for a wire Signal, but the completion happened WITHIN the wait and must be reported // as such — never as a spurious expiry. - server.enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("late\n"), done(COMMAND_ID, 9))), 520); + server.enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("late\n"), done(COMMAND_ID, 9))), 800); try (WinRMClient client = builder().build()) { try (RemoteProcess process = client.command("barely.exe").charset(StandardCharsets.UTF_8).start()) { - assertTrue(process.waitFor(Duration.ofMillis(600)), "completion within the wait must be reported"); + assertTrue(process.waitFor(Duration.ofMillis(1_000)), "completion within the wait must be reported"); assertEquals(9, process.exitCode()); assertEquals("late", process.stdout().readLine()); // The leftover budget could not fit a Signal round trip: none was sent. @@ -715,6 +761,36 @@ void executorsWithoutStreamingSupportRejectTheStreamingEntryPoints() { final WindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor(); assertThrows(UnsupportedOperationException.class, () -> executor.streamWql("ROOT\\CIMV2", "SELECT 1", 1000, 10, 0)); assertThrows(UnsupportedOperationException.class, () -> executor.startCommand("dir", null, 1000)); + assertThrows(UnsupportedOperationException.class, () -> executor.startCommand("dir", null, 1000, true)); + } + + @Test + void executorsOverridingTheHistoricalStartCommandKeepWorkingThroughTheNewOverload() throws Exception { + // A pre-existing executor overrides only the three-argument startCommand: the new + // console-mode default must delegate to it, and only pipe mode may be unsupported. + final CommandCursor canned = new CommandCursor() { + @Override + public Chunk next() { + return null; + } + + @Override + public int exitCode() { + return 0; + } + + @Override + public void close() {} + }; + final WindowsRemoteExecutor legacy = new ScriptedWindowsRemoteExecutor() { + @Override + public CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) { + return canned; + } + }; + + assertEquals(canned, legacy.startCommand("dir", null, 1000, true)); + assertThrows(UnsupportedOperationException.class, () -> legacy.startCommand("dir", null, 1000, false)); } private static byte[] concat(final byte[] a, final byte[] b) { diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java index 1f06362..0dc72d6 100644 --- a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java +++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java @@ -217,7 +217,7 @@ void rejectsInvalidArguments() { }, { "-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 or command)", base }, + { "missing subcommand (wql, command, or shell)", base }, { "wql requires a query", concat(base, "wql", "") }, { "missing required option --hostname", diff --git a/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java new file mode 100644 index 0000000..3f48cf6 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/InteractiveShellTest.java @@ -0,0 +1,560 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.metricshub.winrm.light.FakeWsmanResponses.commandResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.done; +import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; +import static org.metricshub.winrm.light.FakeWsmanResponses.envelope; +import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated; +import static org.metricshub.winrm.light.FakeWsmanResponses.sendResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.stream; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.metricshub.winrm.RemoteProcess; +import org.metricshub.winrm.WinRMClient; +import org.metricshub.winrm.light.FakeWsmanServer; + +/** + * Headless tests of the interactive {@code shell} pump (issue #136, phase 2) against + * {@link FakeWsmanServer}, with a scripted {@link InteractiveShell.InputSource} making every round + * deterministic: input line → Send on the wire, scripted output → local stdout, local EOF → + * {@code End="true"}, Ctrl+C → {@code ctrl_c} Signal, and exit-code propagation. + */ +class InteractiveShellTest { + + private static final String DOMAIN = "FAKE"; + private static final String USER = "user"; + private static final String PASSWORD = "s3cret-Passw0rd"; + + private static final String SHELL_ID = "SHELL-1"; + private static final String COMMAND_ID = "CMD-1"; + + private static final long POLL_MILLIS = 2_000L; + + private FakeWsmanServer server; + + @BeforeEach + void startServer() throws Exception { + server = new FakeWsmanServer(DOMAIN, USER, PASSWORD); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private WinRMClient client() { + return WinRMClient + .builder("127.0.0.1") + .port(server.port()) + .credentials(DOMAIN + "\\" + USER, PASSWORD.toCharArray()) + .timeout(Duration.ofSeconds(10)) + .build(); + } + + private void enqueueStartup() { + server.enqueue(200, envelope(resourceCreated(SHELL_ID))).enqueue(200, envelope(commandResponse(COMMAND_ID))); + } + + /** + * The exact remote process the production shell subcommand starts: {@code cmd.exe /Q} over + * pipe-mode stdin, with the session's single-byte code page on both directions (cmd.exe + * mangles non-ASCII command lines under console code page 65001 — live-verified on Windows + * Server 2008 R2 and 2022). + */ + private static RemoteProcess shellProcess(final WinRMClient client) { + return client + .command(WinRmCli.FluentRemoteOperations.SHELL_COMMAND) + .charset( + WinRmCli.FluentRemoteOperations + .sessionEncoding(WinRmCli.FluentRemoteOperations.DEFAULT_SHELL_CODE_PAGE) + .charset() + ) + .stdin() + .start(); + } + + /** An input source whose {@code nextPiece()} answers are fully scripted, including "not yet". */ + private static final class ScriptedPieces implements InteractiveShell.InputSource { + + private final Deque answers = new ArrayDeque<>(); + private boolean ended; + + /** The given pieces, one per {@code nextPiece()} call, then the end of the local input. */ + private static ScriptedPieces endingAfter(final String... pieces) { + final ScriptedPieces scripted = new ScriptedPieces(); + for (final String piece : pieces) { + scripted.answers.addLast(piece); + } + scripted.ended = true; + return scripted; + } + + /** A local input that never produces anything and never ends (a silent terminal). */ + private static ScriptedPieces silent() { + return new ScriptedPieces(); + } + + @Override + public String nextPiece() { + return answers.pollFirst(); + } + + @Override + public boolean endOfInput() { + return ended && answers.isEmpty(); + } + } + + @Test + void bridgesInputLinesAndOutputAndPropagatesTheExitCode() throws Exception { + enqueueStartup(); + server + // the typed line, CRLF-terminated, traveling WITH the End mark (the input ends after it) + .enqueue(200, envelope(sendResponse())) + // the shell answers and exits + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "MODE PREPARE\r\n".getBytes(StandardCharsets.UTF_8)), + done(COMMAND_ID, 7) + ) + ) + ) + // completion cleanup: the bounded terminate Signal + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + exitCode = InteractiveShell.bridge( + process, + ScriptedPieces.endingAfter("MODE PREPARE\r\n"), + new PrintStream(stdout, true, "UTF-8"), + new PrintStream(stderr, true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS, + null + ); + } + } + + assertEquals(7, exitCode); + assertEquals("MODE PREPARE\r\n", stdout.toString("UTF-8")); + assertEquals("", stderr.toString("UTF-8")); + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("MODE PREPARE\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertTrue(chunks.get(0).end()); + } + + @Test + void forwardsCtrlCAsTheCtrlCSignalAndTheSessionSurvivesIt() throws Exception { + enqueueStartup(); + server + // the forwarded Ctrl+C + .enqueue(200, envelope(signalResponse())) + // the interrupted child returns to the prompt... + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", COMMAND_ID, "^C\r\nC:\\>".getBytes(StandardCharsets.UTF_8)), null)) + ) + // ...and the shell later exits on its own + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final AtomicBoolean interruptRequested = new AtomicBoolean(true); + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + exitCode = InteractiveShell.bridge( + process, + ScriptedPieces.silent(), + new PrintStream(stdout, true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + interruptRequested, + POLL_MILLIS, + null + ); + } + } + + assertEquals(0, exitCode); + assertFalse(interruptRequested.get(), "the flag must be cleared once forwarded"); + assertTrue(stdout.toString("UTF-8").contains("^C")); + + final List requests = server.decryptedRequests(); + assertTrue(requests.get(2).contains("signal/ctrl_c"), requests.get(2)); + // The session went on after the interrupt: output flowed, and only completion terminated it. + assertTrue(requests.get(5).contains("signal/terminate"), requests.get(5)); + } + + @Test + void echoesOutputArrivingBeforeAnyInput() throws Exception { + enqueueStartup(); + server + // round 1: no local input yet — the poll picks up the shell banner + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, "Microsoft Windows\r\nC:\\>".getBytes(StandardCharsets.UTF_8)), + null + ) + ) + ) + // round 2: the user typed "exit" + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + // Nothing on the first nextLine() poll, then "exit", then the local input keeps going + // (never ends): the End mark must NOT be sent, completion alone ends the bridge. + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + final PrintStream out = new PrintStream(stdout, true, "UTF-8"); + exitCode = InteractiveShell.bridge( + process, + new InteractiveShell.InputSource() { + private int round; + + @Override + public String nextPiece() { + round++; + // Round 1 (and the drain call right after "exit"): nothing queued. + return round == 2 ? "exit\r\n" : null; + } + + @Override + public boolean endOfInput() { + return false; + } + }, + out, + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS, + null + ); + } + } + + assertEquals(0, exitCode); + assertTrue(stdout.toString("UTF-8").startsWith("Microsoft Windows")); + + // The banner traveled BEFORE the input: Receive first, the Send only on the next round. + final List requests = server.decryptedRequests(); + assertTrue(requests.get(2).contains(":Receive>"), requests.get(2)); + assertTrue(requests.get(3).contains(":Send>"), requests.get(3)); + + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("exit\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + assertFalse(chunks.get(0).end()); + } + + @Test + void inputQueuedWhileTheFinalReceiveWasInFlightDoesNotHideTheExitCode() throws Exception { + enqueueStartup(); + server + // round 1: the final Receive carries the Done state... + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 3)))) + // ...and the completion cleanup Signal follows on the next round + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + // A line shows up right AFTER the final Receive: it can no longer be consumed, and it must + // not turn the session into a failure — the exit code wins. + final InteractiveShell.InputSource latePiece = new InteractiveShell.InputSource() { + private int round; + + @Override + public String nextPiece() { + round++; + return round == 2 ? "too late\r\n" : null; + } + + @Override + public boolean endOfInput() { + return false; + } + }; + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + exitCode = InteractiveShell.bridge( + process, + latePiece, + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS, + null + ); + } + } + + assertEquals(3, exitCode); + // The late line never reached the wire: no Send left the client. + assertTrue(server.stdinChunks().isEmpty()); + assertTrue(server.decryptedRequests().stream().noneMatch(request -> request.contains(":Send>"))); + } + + @Test + void aFireHoseOfInputIsForwardedInBoundedBatches() throws Exception { + final char[] big = new char[InteractiveShell.MAX_INPUT_CHARS_PER_ROUND + 1_000]; + java.util.Arrays.fill(big, 'x'); + final String hugeLine = new String(big); + + enqueueStartup(); + server + // round 1: the first batch (capped) travels alone... + .enqueue(200, envelope(sendResponse())) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", COMMAND_ID, "ok\r\n".getBytes(StandardCharsets.UTF_8)), null)) + ) + // ...the leftover line follows on the next round, with the End mark + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + exitCode = InteractiveShell.bridge( + process, + ScriptedPieces.endingAfter(hugeLine + "\r\n", "exit\r\n"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS, + null + ); + } + } + + assertEquals(0, exitCode); + // Two bounded batches instead of one unbounded drain: the oversized record was SPLIT at + // the round budget — the output side polled in between — and nothing was lost or + // reordered. + final List chunks = server.stdinChunks(); + assertEquals(2, chunks.size()); + assertEquals(InteractiveShell.MAX_INPUT_CHARS_PER_ROUND, chunks.get(0).data().length); + assertFalse(chunks.get(0).end()); + assertTrue(chunks.get(1).end()); + final ByteArrayOutputStream reassembled = new ByteArrayOutputStream(); + for (final FakeWsmanServer.StdinChunk chunk : chunks) { + reassembled.writeBytes(chunk.data()); + } + assertArrayEquals((hugeLine + "\r\nexit\r\n").getBytes(StandardCharsets.UTF_8), reassembled.toByteArray()); + } + + @Test + void queuedInputSourceNormalizesLineEndingsAndReportsTheEnd() throws Exception { + // The production InputSource behind InteractiveShell.run: fed by the helper thread reading + // the local standard input. Here the whole stream is read synchronously (readAll returns + // once the input ends), making the outcome deterministic. Every line-ending flavor (LF, + // CRLF, lone CR) becomes the CRLF the remote cmd.exe expects, and a final unterminated + // record is delivered as-is. + final InteractiveShell.QueuedInputSource pieces = new InteractiveShell.QueuedInputSource(); + pieces.readAll(new ByteArrayInputStream("first\nsecond\r\nthird\rtail".getBytes(StandardCharsets.UTF_8))); + + assertFalse(pieces.endOfInput(), "queued pieces must be consumed before the end of input is reported"); + assertEquals("first\r\n", pieces.nextPiece()); + assertEquals("second\r\n", pieces.nextPiece()); + assertEquals("third\r\n", pieces.nextPiece()); + assertFalse(pieces.endOfInput()); + assertEquals("tail", pieces.nextPiece()); + assertNull(pieces.nextPiece()); + assertTrue(pieces.endOfInput()); + assertNull(pieces.nextPiece()); + } + + @Test + void theSyntheticExitIsNeverGluedOntoAnUnterminatedRecord() throws Exception { + enqueueStartup(); + server + // one Send: the unterminated record, its missing line ending, and the exit + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + final int exitCode; + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + // A local input ending mid-line (printf 'echo hi' with no trailing newline, or + // Ctrl+D typed mid-line): "echo hiexit" would run instead, and the session would + // hang forever — an idle shell deliberately never times out. + exitCode = InteractiveShell.bridge( + process, + ScriptedPieces.endingAfter("echo hi"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS, + "exit" + ); + } + } + + assertEquals(0, exitCode); + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("echo hi\r\nexit\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + // Console-mode stdin has no end of input: the exit command replaces the End mark. + assertFalse(chunks.get(0).end()); + } + + @Test + void theSyntheticExitFollowsAProperlyTerminatedRecordDirectly() throws Exception { + enqueueStartup(); + server + .enqueue(200, envelope(sendResponse())) + .enqueue(200, envelope(receiveResponse("", done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + enqueueShellDeletion(server); + + try (WinRMClient client = client()) { + try (RemoteProcess process = shellProcess(client)) { + InteractiveShell.bridge( + process, + ScriptedPieces.endingAfter("echo hi\r\n"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new PrintStream(new ByteArrayOutputStream(), true, "UTF-8"), + new AtomicBoolean(), + POLL_MILLIS, + "exit" + ); + } + } + + // No spurious blank line before the exit when the record already ended one. + final List chunks = server.stdinChunks(); + assertEquals(1, chunks.size()); + assertArrayEquals("echo hi\r\nexit\r\n".getBytes(StandardCharsets.UTF_8), chunks.get(0).data()); + } + + @Test + void aLocalReadFailureFailsTheSourceInsteadOfEndingIt() throws Exception { + // A failing local stdin must NOT read as a normal end of input: the remote shell would + // execute the truncated input and the CLI would report success despite the local error. + final java.io.InputStream failing = new java.io.InputStream() { + private int calls; + + @Override + public int read() throws java.io.IOException { + calls++; + if (calls <= 3) { + return "ok\n".charAt(calls - 1); + } + throw new java.io.IOException("disk error"); + } + }; + final InteractiveShell.QueuedInputSource pieces = new InteractiveShell.QueuedInputSource(); + pieces.readAll(failing); + + assertEquals("ok\r\n", pieces.nextPiece()); + final java.io.IOException failure = org.junit.jupiter.api.Assertions.assertThrows( + java.io.IOException.class, + pieces::nextPiece + ); + assertEquals("disk error", failure.getCause().getMessage()); + assertTrue(pieces.endOfInput()); + } + + @Test + void localInputCharsetProbesTheJdkSignalsBeforeTheProcessDefault() { + // stdin.encoding (recent JDKs) wins over everything. + assertEquals( + StandardCharsets.ISO_8859_1, + InteractiveShell.localInputCharset(name -> "stdin.encoding".equals(name) ? "ISO-8859-1" : null, null) + ); + // sun.stdin.encoding: what a Windows console reports on older JDKs. + assertEquals( + StandardCharsets.US_ASCII, + InteractiveShell.localInputCharset(name -> "sun.stdin.encoding".equals(name) ? "US-ASCII" : null, null) + ); + // An unknown name is skipped and the probing continues down to native.encoding. + assertEquals( + StandardCharsets.UTF_16BE, + InteractiveShell.localInputCharset( + name -> "stdin.encoding".equals(name) ? "no-such-charset" : "native.encoding".equals(name) ? "UTF-16BE" : null, + null + ) + ); + // No signal at all: the process default. + assertEquals( + java.nio.charset.Charset.defaultCharset(), + InteractiveShell.localInputCharset(name -> null, null) + ); + } + + @Test + void queuedInputSourceSlicesANewlineFreeRecordInsteadOfMaterializingIt() throws Exception { + // A giant record without any newline (minified JSON, base64) must be queued in bounded + // slices: the memory bound holds by characters, never by lines. + final char[] record = new char[InteractiveShell.MAX_QUEUED_PIECE_CHARS + 500]; + java.util.Arrays.fill(record, 'x'); + final InteractiveShell.QueuedInputSource pieces = new InteractiveShell.QueuedInputSource(); + pieces.readAll(new ByteArrayInputStream((new String(record) + "\n").getBytes(StandardCharsets.UTF_8))); + + final String first = pieces.nextPiece(); + final String second = pieces.nextPiece(); + assertEquals(InteractiveShell.MAX_QUEUED_PIECE_CHARS, first.length()); + assertEquals(500 + 2, second.length()); + assertTrue(second.endsWith("\r\n")); + assertEquals(new String(record) + "\r\n", first + second); + assertNull(pieces.nextPiece()); + assertTrue(pieces.endOfInput()); + } +} diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 016f9b7..5960811 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -38,6 +38,7 @@ import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import org.metricshub.winrm.light.FakeWsmanResponses; import org.metricshub.winrm.light.FakeWsmanServer; class WinRmCliTest { @@ -52,6 +53,7 @@ void helpAndVersionDoNotConnect() throws Exception { final Invocation help = invoke(new String[] { "--help" }, arguments -> failingRemote()); assertEquals(0, help.exitCode); assertTrue(help.stdout.contains("command|cmd|exec|run")); + assertTrue(help.stdout.contains("[options] shell")); assertTrue(help.stdout.contains("-P, --port")); assertTrue(help.stdout.contains("--kerberos-kdc")); assertTrue(help.stdout.contains("--kerberos-realm")); @@ -107,6 +109,212 @@ void forwardsCommandStreamsAndExitCode() throws Exception { assertEquals("echo \"hello world\"", remote.command); } + @Test + void shellSubcommandBridgesTheRemoteShellAndPropagatesItsExitCode() throws Exception { + final FakeRemote remote = new FakeRemote(); + remote.stdoutChunks = List.of("Microsoft Windows\r\nC:\\>"); + remote.commandExitCode = 3; + + final Invocation invocation = invoke(concat(REQUIRED, "shell"), args -> remote); + + assertEquals(3, invocation.exitCode); + assertTrue(remote.shellStarted); + assertEquals("Microsoft Windows\r\nC:\\>", invocation.stdout); + assertTrue(remote.closed); + } + + @Test + void shellRunsAQuietCmdUnderASingleByteCodePage() throws Exception { + // Full stack against the in-process WSMan server, through the CLI's real connect factory. + // The shell must be echo-free (cmd.exe /Q) and must NOT run under console code page 65001: + // a remote cmd.exe decodes the command lines it reads from stdin one byte at a time under + // that page, losing every non-ASCII character. The ANSI code page probe precedes the shell + // and its answer becomes the shell's console code page. + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + server.enqueue( + 200, + FakeWsmanResponses.envelope( + FakeWsmanResponses.enumerationDone(FakeWsmanResponses.instance("Win32_OperatingSystem", "CodeSet", "1252")) + ) + ); + enqueueShellCreation(server); + server + .enqueue(200, FakeWsmanResponses.envelope(FakeWsmanResponses.commandResponse("CMD-1"))) + .enqueue( + 200, + FakeWsmanResponses.envelope(FakeWsmanResponses.receiveResponse("", FakeWsmanResponses.done("CMD-1", 0))) + ) + .enqueue(200, FakeWsmanResponses.envelope(FakeWsmanResponses.signalResponse())); + enqueueShellDeletion(server); + + // A local input that never delivers anything: the pump only ever polls, keeping the + // scripted exchange deterministic (the daemon reader thread blocks forever). + final java.io.InputStream never = new java.io.InputStream() { + @Override + public int read() { + try { + new java.util.concurrent.CountDownLatch(1).await(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + return -1; + } + }; + + final Invocation invocation = invoke( + new String[] + { + "-h", + "127.0.0.1", + "-P", + String.valueOf(server.port()), + "-u", + "FAKE\\user", + "-p", + "secret", + "-t", + "10000", + "shell" + }, + WinRmCli::connect, + new WinRmCli.LocalInput(true, never) + ); + + assertEquals(0, invocation.exitCode); + final List requests = server.decryptedRequests(); + assertTrue(requests.get(0).contains("Win32_OperatingSystem"), requests.get(0)); + final String create = requests.get(1); + assertTrue(create.contains("1252"), create); + final String command = requests.get(2); + assertTrue(command.contains("cmd.exe /Q"), command); + assertTrue(command.contains("FALSE"), command); + } + } + + @Test + void theShellCodePageAndCharsetAreAlwaysResolvedTogether() { + // A page this JVM has a charset for is used as reported... + assertEquals(1252, WinRmCli.FluentRemoteOperations.sessionEncoding(1252).codePage()); + assertEquals( + java.nio.charset.Charset.forName("windows-1252"), + WinRmCli.FluentRemoteOperations.sessionEncoding(1252).charset() + ); + assertEquals(850, WinRmCli.FluentRemoteOperations.sessionEncoding(850).codePage()); + assertEquals( + java.nio.charset.Charset.forName("IBM850"), + WinRmCli.FluentRemoteOperations.sessionEncoding(850).charset() + ); + + // ...but 65001 — what a host configured for UTF-8 reports as its ANSI page — is precisely + // the page an interactive cmd.exe cannot read command lines under, so BOTH the page and + // the charset fall back rather than pinning the shell to a page the session cannot use. + assertEquals(1252, WinRmCli.FluentRemoteOperations.sessionEncoding(65001).codePage()); + assertEquals( + java.nio.charset.Charset.forName("windows-1252"), + WinRmCli.FluentRemoteOperations.sessionEncoding(65001).charset() + ); + + // An unknown page falls back on both counts too: a charset that does not match the shell's + // code page would corrupt the session in both directions. + assertEquals(1252, WinRmCli.FluentRemoteOperations.sessionEncoding(999_999).codePage()); + assertEquals( + java.nio.charset.Charset.forName("windows-1252"), + WinRmCli.FluentRemoteOperations.sessionEncoding(999_999).charset() + ); + } + + @Test + void shellTakesNoArgument() throws Exception { + final Invocation invocation = invoke(concat(REQUIRED, "shell", "cmd.exe"), args -> failingRemote()); + assertEquals(WinRmCli.EXIT_USAGE, invocation.exitCode); + assertTrue(invocation.stderr.contains("shell takes no argument")); + } + + @Test + void shellRejectsATimeoutBelowThePollFloor() throws Exception { + // A --timeout below one poll round trip would make the session pump spin locally without + // ever fetching output: the WSMan service holds a bounded Receive for at least 500 ms. + final Invocation invocation = invoke(concat(REQUIRED, "-t", "500", "shell"), args -> failingRemote()); + assertEquals(WinRmCli.EXIT_USAGE, invocation.exitCode); + assertTrue(invocation.stderr.contains("shell requires --timeout of at least 1000 milliseconds")); + + // The same timeout stays perfectly valid for the other subcommands. + final FakeRemote remote = new FakeRemote(); + assertEquals(0, invoke(concat(REQUIRED, "-t", "500", "command", "whoami"), args -> remote).exitCode); + } + + @Test + void pipedLocalStandardInputIsForwardedToTheCommandButAConsoleIsNot() throws Exception { + final java.io.ByteArrayInputStream piped = new java.io.ByteArrayInputStream( + "beta\nalpha\n".getBytes(StandardCharsets.UTF_8) + ); + final FakeRemote remote = new FakeRemote(); + Invocation invocation = invoke( + concat(REQUIRED, "command", "sort"), + args -> remote, + new WinRmCli.LocalInput(false, piped) + ); + assertEquals(0, invocation.exitCode); + assertEquals(piped, remote.forwardedStdin); + + // From an interactive console, nothing is forwarded implicitly. + final FakeRemote interactive = new FakeRemote(); + invocation = invoke( + concat(REQUIRED, "command", "sort"), + args -> interactive, + new WinRmCli.LocalInput(true, piped) + ); + assertEquals(0, invocation.exitCode); + org.junit.jupiter.api.Assertions.assertNull(interactive.forwardedStdin); + } + + @Test + void detectsRedirectedStdinByProbingTheInputItself() { + // Bytes already waiting on a non-console stdin: a pipe or a redirected file. + assertTrue(WinRmCli.stdinHasAvailableInput(new java.io.ByteArrayInputStream(new byte[] { 1 }))); + + // Nothing waiting: typically an interactive terminal whose OUTPUT is redirected + // (System.console() is null then too) — consuming it would hang the CLI. + assertFalse(WinRmCli.stdinHasAvailableInput(new java.io.ByteArrayInputStream(new byte[0]))); + + // A probe failure counts as not redirected: never risk blocking on a terminal. + assertFalse( + WinRmCli.stdinHasAvailableInput( + new java.io.InputStream() { + @Override + public int read() { + return -1; + } + + @Override + public int available() throws java.io.IOException { + throw new java.io.IOException("probe failure"); + } + } + ) + ); + } + + @Test + void explicitStdinOptionForcesForwardingAndRequiresTheCommandSubcommand() throws Exception { + // --stdin forwards even when the local input looks interactive (the undetectable cases: + // an empty redirection, a pipe whose producer starts slowly). + final java.io.ByteArrayInputStream local = new java.io.ByteArrayInputStream(new byte[0]); + final FakeRemote remote = new FakeRemote(); + final Invocation invocation = invoke( + concat(REQUIRED, "--stdin", "command", "sort"), + args -> remote, + new WinRmCli.LocalInput(true, local) + ); + assertEquals(0, invocation.exitCode); + assertEquals(local, remote.forwardedStdin); + + // The option is meaningless outside the command subcommand. + final Invocation rejected = invoke(concat(REQUIRED, "--stdin", "wql", "SELECT 1"), args -> failingRemote()); + assertEquals(WinRmCli.EXIT_USAGE, rejected.exitCode); + assertTrue(rejected.stderr.contains("--stdin requires the command subcommand")); + } + @Test void mapsUsageTimeoutConnectionAuthenticationAndProtocolFailures() throws Exception { assertEquals( @@ -333,6 +541,34 @@ private static Invocation invoke( } } + private static Invocation invoke( + final String[] arguments, + final WinRmCli.RemoteFactory factory, + final WinRmCli.LocalInput localInput + ) throws Exception { + final ByteArrayOutputStream stdoutBytes = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderrBytes = new ByteArrayOutputStream(); + try ( + PrintStream stdout = new PrintStream(stdoutBytes, true, StandardCharsets.UTF_8.name()); + PrintStream stderr = new PrintStream(stderrBytes, true, StandardCharsets.UTF_8.name())) { + final int exitCode = WinRmCli.run( + arguments, + stdout, + stderr, + factory, + () -> { + throw new AssertionError("No password prompt expected"); + }, + localInput + ); + return new Invocation( + exitCode, + stdoutBytes.toString(StandardCharsets.UTF_8.name()), + stderrBytes.toString(StandardCharsets.UTF_8.name()) + ); + } + } + private static String[] concat(final String[] prefix, final String... suffix) { final String[] result = new String[prefix.length + suffix.length]; System.arraycopy(prefix, 0, result, 0, prefix.length); @@ -369,6 +605,8 @@ private static final class FakeRemote implements WinRmCli.RemoteOperations { private int commandExitCode; private Exception failure; private String command; + private java.io.InputStream forwardedStdin; + private boolean shellStarted; private boolean closed; @Override @@ -382,16 +620,35 @@ public void streamWql(final String query, final long timeout, final Consumer stdoutConsumer, final Consumer stderrConsumer ) throws Exception { this.command = command; + this.forwardedStdin = stdin; failIfConfigured(); stdoutChunks.forEach(stdoutConsumer); stderrChunks.forEach(stderrConsumer); return commandExitCode; } + @Override + public int shell( + final long timeout, + final java.io.InputStream localInput, + final java.io.PrintStream out, + final java.io.PrintStream err, + final java.util.concurrent.atomic.AtomicBoolean interruptRequested + ) throws Exception { + shellStarted = true; + failIfConfigured(); + stdoutChunks.forEach(chunk -> { + out.print(chunk); + out.flush(); + }); + return commandExitCode; + } + @Override public void close() { closed = true; diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java index af9da42..818d509 100644 --- a/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanResponses.java @@ -199,6 +199,15 @@ public static String signalResponse() { return ""; } + /** + * The SendResponse body answering a stdin Send. + * + * @return the SendResponse body + */ + public static String sendResponse() { + return ""; + } + /** * A complete WSMan fault envelope (not to be wrapped in {@link #envelope(String)}). * diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java index fe9e157..56c99b4 100644 --- a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java @@ -186,6 +186,57 @@ public List decryptedRequests() { return new ArrayList<>(decryptedRequests); } + /** One stdin chunk carried by a WSMan Send request: its decoded bytes and its End flag. */ + public static final class StdinChunk { + + private final byte[] data; + private final boolean end; + + private StdinChunk(final byte[] data, final boolean end) { + this.data = data; + this.end = end; + } + + /** + * @return the decoded stdin bytes of this chunk + */ + public byte[] data() { + return data.clone(); + } + + /** + * @return whether the chunk was flagged as the end of input + */ + public boolean end() { + return end; + } + } + + /** + * The stdin chunks received so far through WSMan Send requests, in order: base64-decoded + * content and End flag, extracted from the decrypted request bodies. + * + * @return the stdin chunks, in the order they were received + */ + public List stdinChunks() { + final List chunks = new ArrayList<>(); + final java.util.regex.Pattern stream = java.util.regex.Pattern.compile( + "]*)>([^<]*)" + ); + for (final String request : decryptedRequests) { + final java.util.regex.Matcher matcher = stream.matcher(request); + while (matcher.find()) { + chunks.add( + new StdinChunk( + Base64.getDecoder().decode(matcher.group(2)), + matcher.group(1).contains("End=\"true\"") + ) + ); + } + } + return chunks; + } + @Override public void close() { closed = true;