From 18f0175a95a08f55abd9c20bdc52db9757f63d18 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 28 Jul 2026 17:51:04 +0200 Subject: [PATCH] Decode command output as UTF-8; stop guessing the remote code page (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote shell was created with console code page 437 while its output was decoded with the charset from Win32_OperatingSystem.CodeSet. That property is the remote machine's ANSI code page, not the console (OEM) one, so the two could only agree on a US-English host. On a French Windows Server 2022, `dir` came back with "num‚ro de s‚rie" and "786ÿ432": CP437's 0x82 (é) and 0xFF (NBSP) read as windows-1252. Create the shell with code page 65001 and decode as UTF-8 unconditionally. The output charset is then known without asking, it is lossless for every locale (CJK included, which no single-byte OEM page can carry), and the SELECT CodeSet FROM Win32_OperatingSystem round trip made before every command is gone. Verified against a French Windows Server 2022: "Le numéro de série", the NBSP thousands separator, and remote-generated Japanese, Greek and Cyrillic all come back byte-exact. A few legacy tools (net.exe, chcp.com) ignore the console code page and write OEM bytes regardless; charset(Charset) decodes those, and the README and site docs now say so. WindowsRemoteProcessUtils.getWindowsEncodingCharset() is deprecated: the library no longer calls it, and it never answered the question it was being asked. Co-Authored-By: Claude Fable 5 --- README.md | 36 ++++++++- .../org/metricshub/winrm/CommandRequest.java | 22 +++--- .../org/metricshub/winrm/WinRMClient.java | 23 ------ .../winrm/WindowsRemoteExecutor.java | 11 ++- .../winrm/WindowsRemoteProcessUtils.java | 8 ++ .../winrm/command/WinRMCommandExecutor.java | 22 ++---- .../org/metricshub/winrm/light/Envelopes.java | 9 ++- .../metricshub/winrm/light/WsmanClient.java | 6 +- src/site/markdown/cli.md | 10 ++- src/site/markdown/commands.md | 38 ++++++++-- src/site/markdown/timeouts-and-errors.md | 4 +- .../org/metricshub/winrm/WinRMClientTest.java | 75 ++++++++++++------- .../metricshub/winrm/cli/WinRmCliTest.java | 33 ++++---- .../command/WinRMCommandExecutorTest.java | 14 +--- .../winrm/light/WsmanProtocolTest.java | 4 +- 15 files changed, 191 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index fb86fb5..c52b372 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,39 @@ Connection-scoped options on the builder: `https()`, `port(int)`, Per-operation options: `namespace(...)`, `timeout(...)`, and for WQL enumeration tuning `pageSize(int)` (WS-Enumeration `MaxElements`, 32000 by default) and `pullTimeout(Duration)` -(`MaxTime` per Pull). Commands accept `workingDirectory(String)`, `charset(Charset)` (detected -from the remote code set by default), and `upload(Path...)` to copy local script files and rewrite -the command to reference the remote copies. +(`MaxTime` per Pull). Commands accept `workingDirectory(String)`, `charset(Charset)` (see +[Character encoding](#character-encoding)), and `upload(Path...)` to copy local script files and +rewrite the command to reference the remote copies. + +### Character encoding + +The remote command shell is created with console code page **65001**, so command output is decoded +as **UTF-8** — whatever the remote machine's locale. Accented and non-Latin characters come back +exactly as the remote host wrote them, and nothing needs to be detected or configured: + +```java +// On a French Windows host: +client.command("vol").execute().stdout(); // " Le numéro de série du volume est …" +``` + +A few legacy console tools — `net.exe` and `chcp.com` are the known ones — ignore the console code +page and write text pre-converted to the machine's **OEM** code page. Their accented characters +cannot be decoded as UTF-8 (they arrive as `U+FFFD`); decode such a command with the matching OEM +charset instead: + +```java +client.command("net user Administrateur") + .charset(Charset.forName("IBM850")) // French/Western European OEM code page + .execute(); +``` + +WQL results are unaffected: they travel as UTF-8 inside the SOAP envelope. + +> **Changed in 2.0.00** — earlier versions ran a `SELECT CodeSet FROM Win32_OperatingSystem` query +> before each command and decoded the output with the code page it reported. That is the remote +> machine's *ANSI* code page, which never matched what the shell emitted, so all non-ASCII output +> was mangled on non-English hosts. `WindowsRemoteProcessUtils.getWindowsEncodingCharset()` is +> deprecated as a result, and the extra query is gone. ### Streaming diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 8ab808e..6947515 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -33,7 +33,6 @@ import org.metricshub.winrm.exceptions.WinRMClientException; import org.metricshub.winrm.exceptions.WinRMTimeoutException; import org.metricshub.winrm.exceptions.WindowsRemoteException; -import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; /** * A command being prepared for execution, created by {@link WinRMClient#command(String)}. @@ -81,7 +80,7 @@ public CommandRequest workingDirectory(final String workingDirectory) { /** * Set the timeout of this command. For {@link #execute()} it is a wall-clock deadline covering - * file uploads, encoding detection, and the command itself; for {@link #start()} it is an + * file uploads and the command itself; for {@link #start()} it is an * inactivity timeout — the longest silence tolerated from the server between two * responses, with no overall deadline. Default: the client's timeout. * @@ -94,8 +93,11 @@ public CommandRequest timeout(final Duration timeout) { } /** - * Set the charset used to decode the command output. Default: detected from the remote - * operating system's code set (one extra WQL query, cached on the client). + * Set the charset used to decode the command output. Default: + * {@link WindowsRemoteExecutor#SHELL_OUTPUT_CHARSET} (UTF-8), which is what the remote shell + * emits — its console code page is set to 65001 when the shell is created, whatever the remote + * locale. Override this only for a command that changes the console code page itself (a leading + * {@code chcp}) or writes raw bytes in another encoding to its standard output. * * @param charset the output charset * @return this request @@ -212,7 +214,7 @@ public CommandResult execute() { return Utils.execute(() -> drainWithCallbacks(prepared, remaining, start), remaining); } catch (final TimeoutException e) { throw timeoutException(e); - } catch (final IOException | WqlQuerySyntaxException e) { + } catch (final IOException e) { throw new WinRMClientException(e.getMessage(), e); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); @@ -241,8 +243,8 @@ public CommandResult execute() { * The process must be closed — use try-with-resources. It holds the client's serial * connection until the command completes or the handle is closed; closing early terminates * the remote command (WinRM terminate {@code Signal}). The timeout acts as an - * inactivity timeout — see {@link RemoteProcess}. File uploads and encoding detection - * run here, before the command starts. + * inactivity timeout — see {@link RemoteProcess}. File uploads run here, before the + * command starts. * * @return the running process handle, to use with try-with-resources * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the command startup times out @@ -263,7 +265,7 @@ public RemoteProcess start() { return new RemoteProcess(cursor, prepared.charset, client.hostname(), timeout); } catch (final TimeoutException e) { throw timeoutException(e); - } catch (final IOException | WqlQuerySyntaxException e) { + } catch (final IOException e) { throw new WinRMClientException(e.getMessage(), e); } catch (final WindowsRemoteException e) { throw WinRMClient.translate(e); @@ -290,7 +292,7 @@ private static final class Prepared { * resolve the output charset. */ private Prepared prepare(final long timeoutMillis, final long start) - throws IOException, TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + throws IOException, TimeoutException, WindowsRemoteException { String actualCommand = commandLine; String actualWorkingDirectory = workingDirectory; @@ -309,7 +311,7 @@ private Prepared prepare(final long timeoutMillis, final long start) actualWorkingDirectory = null; } - final Charset actualCharset = charset != null ? charset : client.detectCharset(timeoutMillis, start); + final Charset actualCharset = charset != null ? charset : WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET; return new Prepared(actualCommand, actualWorkingDirectory, actualCharset); } diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index ee3e845..e986b62 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -22,7 +22,6 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; -import java.nio.charset.Charset; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; @@ -33,7 +32,6 @@ import org.metricshub.winrm.exceptions.WinRMException; import org.metricshub.winrm.exceptions.WinRMTimeoutException; import org.metricshub.winrm.exceptions.WindowsRemoteException; -import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; import org.metricshub.winrm.service.WinRMEndpoint; import org.metricshub.winrm.service.WinRMExecutorFactory; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; @@ -85,10 +83,6 @@ public final class WinRMClient implements AutoCloseable { private final String namespace; private final Duration timeout; - // The remote OS code set does not change during a session: detect it once, on the first - // command that needs it, and reuse it for every later command. - private volatile Charset detectedCharset; - private WinRMClient( final WindowsRemoteExecutor executor, final String hostname, @@ -192,23 +186,6 @@ Duration defaultTimeout() { return timeout; } - /** - * The charset of the remote command output, detected from the remote OS code set on first - * use and cached for the lifetime of the client. - */ - Charset detectCharset(final long timeoutMillis, final long start) - throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { - Charset charset = detectedCharset; - if (charset == null) { - charset = WindowsRemoteProcessUtils.getWindowsEncodingCharset( - executor, - TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to detect the remote encoding") - ); - detectedCharset = charset; - } - return charset; - } - /** * Translate a legacy checked exception into the unchecked hierarchy: when a typed * {@link WinRMClientException} (fault, authentication) is in the cause chain, it is unwrapped diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index 32dfd34..ebf8dc9 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -21,6 +21,7 @@ */ import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.concurrent.TimeoutException; @@ -34,6 +35,13 @@ public interface WindowsRemoteExecutor extends AutoCloseable { */ int DEFAULT_WQL_MAX_ELEMENTS = 32000; + /** + * Charset of the output of every command run through this executor: UTF-8, because the remote + * command shell is created with console code page 65001. It is not the remote machine's ANSI or + * OEM code page, and it does not depend on the remote locale. + */ + Charset SHELL_OUTPUT_CHARSET = StandardCharsets.UTF_8; + /** *

* Execute a WQL query and process its result. @@ -151,7 +159,8 @@ default CommandCursor startCommand(final String command, final String workingDir * * @param command The command to execute * @param workingDirectory Path of the directory for the spawned process on the remote system (can be null) - * @param charset The charset + * @param charset The charset decoding the command output; {@code null} uses + * {@link #SHELL_OUTPUT_CHARSET}, which is what the remote shell actually emits * @param timeout Timeout in milliseconds * @return The command result * @throws WindowsRemoteException For any problem encountered diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteProcessUtils.java b/src/main/java/org/metricshub/winrm/WindowsRemoteProcessUtils.java index 460d802..e97e085 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteProcessUtils.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteProcessUtils.java @@ -86,7 +86,15 @@ private WindowsRemoteProcessUtils() {} * @throws WindowsRemoteException For any problem encountered on remote * @see * Win32_OperatingSystem class + * @deprecated Not the charset of remote command output, and never was: {@code CodeSet} is the + * remote machine's ANSI code page, while {@code cmd.exe} writes its output in + * the console (OEM) code page — the two differ on every non-English locale. + * Command output is now decoded with + * {@link WindowsRemoteExecutor#SHELL_OUTPUT_CHARSET}, the code page the remote shell + * is created with. Use this method only to decode data that a Windows application + * genuinely wrote in the ANSI code page. */ + @Deprecated(since = "2.0.00", forRemoval = false) public static Charset getWindowsEncodingCharset( final WindowsRemoteExecutor windowsRemoteExecutor, final long timeout diff --git a/src/main/java/org/metricshub/winrm/command/WinRMCommandExecutor.java b/src/main/java/org/metricshub/winrm/command/WinRMCommandExecutor.java index 22e12e9..55815e4 100644 --- a/src/main/java/org/metricshub/winrm/command/WinRMCommandExecutor.java +++ b/src/main/java/org/metricshub/winrm/command/WinRMCommandExecutor.java @@ -21,7 +21,6 @@ */ import java.io.IOException; -import java.nio.charset.Charset; import java.nio.file.Path; import java.util.Collections; import java.util.List; @@ -33,9 +32,7 @@ import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WindowsRemoteCommandResult; import org.metricshub.winrm.WindowsRemoteExecutor; -import org.metricshub.winrm.WindowsRemoteProcessUtils; import org.metricshub.winrm.exceptions.WindowsRemoteException; -import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; import org.metricshub.winrm.service.WinRMEndpoint; import org.metricshub.winrm.service.WinRMExecutorFactory; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; @@ -108,12 +105,12 @@ public static WindowsRemoteCommandResult execute( authentications )) { if (localFiles.isEmpty()) { - final Charset charset = WindowsRemoteProcessUtils.getWindowsEncodingCharset( - winRMService, - TimeoutHelper.getRemainingTime(timeout, start, "No time left to retrieve the code set") + return winRMService.executeCommand( + command, + workingDirectory, + WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET, + timeout ); - - return winRMService.executeCommand(command, workingDirectory, charset, timeout); } // Copy the specified list of files through the command shell, and update the command accordingly @@ -124,19 +121,12 @@ public static WindowsRemoteCommandResult execute( TimeoutHelper.getRemainingTime(timeout, start, "No time left to copy the local files") ); - final Charset charset = WindowsRemoteProcessUtils.getWindowsEncodingCharset( - winRMService, - TimeoutHelper.getRemainingTime(timeout, start, "No time left to retrieve the code set") - ); - return winRMService.executeCommand( String.format("CMD.EXE /C (%s)", localFilesUpdatedCommand), null, - charset, + WindowsRemoteExecutor.SHELL_OUTPUT_CHARSET, TimeoutHelper.getRemainingTime(timeout, start, "No time left to execute command") ); - } catch (final WqlQuerySyntaxException e) { - throw new IOException(e); } } } diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index 11a650f..b120b22 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -55,6 +55,13 @@ final class Envelopes { private static final int MAX_ENVELOPE_SIZE = 153600; + /** + * Console code page of the remote shell: UTF-8. The shell's output charset must be one the + * client knows without asking, and it must be able to carry every locale's characters — an OEM + * page like 437 can encode neither {@code é} on a French host nor anything CJK at all. + */ + private static final String CODEPAGE_UTF8 = "65001"; + private Envelopes() {} // --- WQL --------------------------------------------------------------- @@ -121,7 +128,7 @@ static String release(final String url, final String namespace, final String con static String createShell(final String url, final String workingDirectory, final long timeoutMs) { final String optionSet = "" + "TRUE" + - "437" + + "" + CODEPAGE_UTF8 + "" + ""; final String workingDir = (workingDirectory == null || workingDirectory.trim().isEmpty()) ? "" diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 2276e56..95c9c61 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -36,6 +36,7 @@ import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; +import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.exceptions.WinRMAuthenticationException; import org.metricshub.winrm.exceptions.WinRMFaultException; import org.w3c.dom.Document; @@ -413,7 +414,8 @@ static final class CommandOutput { * * @param commandLine the command line to run * @param workingDirectory working directory of the shell (only honored when the shell is created) - * @param charset the charset decoding the output streams + * @param charset the charset decoding the output streams; {@code null} uses + * {@link WindowsRemoteExecutor#SHELL_OUTPUT_CHARSET} * @param operationTimeoutMs this operation's timeout, driving the WSMan OperationTimeout header * and the socket read timeout */ @@ -423,7 +425,7 @@ CommandOutput executeCommand( final Charset charset, final long operationTimeoutMs ) throws Exception { - final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; + 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)) { diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index dc2e04c..0d5b725 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -88,8 +88,10 @@ failure can therefore leave partial output on standard output, signalled by the Remote stdout and stderr are forwarded **live** to the corresponding local streams while the command runs — each chunk is flushed as it arrives, so a long-running command can be followed in -real time. The output is decoded with the remote host's active code page, detected automatically -before the command starts. +real time. The output is decoded as UTF-8: the remote shell is created with console code page +65001, so no code-page detection is needed and non-ASCII output survives whatever the remote +locale. See [Character encoding](commands.html#character-encoding) for the two legacy tools that +ignore the console code page. ## Timeout semantics @@ -98,8 +100,8 @@ before the command starts. * For `wql`, it is the **inactivity timeout** of the stream — the longest tolerated silence between two server responses. A large result can stream for longer than the timeout, as long as the server keeps answering. -* For `command`, it is the **overall deadline** covering the encoding detection and the command - itself. +* For `command`, it is the **overall deadline** covering the command itself and any file + uploads. 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 56ac550..75f284e 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -43,8 +43,8 @@ Everything between `command(...)` and `execute()` is optional: | Option | Default | Meaning | | --- | --- | --- | -| `timeout(Duration)` | the client's timeout | Wall-clock deadline covering file uploads, encoding detection, and the command itself with `execute()`; inactivity timeout with `start()`. | -| `charset(Charset)` | detected from the remote code set | The charset used to decode the command output (see below). | +| `timeout(Duration)` | the client's timeout | Wall-clock deadline covering file uploads and the command itself with `execute()`; inactivity timeout with `start()`. | +| `charset(Charset)` | `UTF-8` | The charset used to decode the command output (see below). | | `workingDirectory(String)` | remote default | Working directory of the remote process. The remote shell is created by the client's **first** command and reused afterward, so this only takes effect on that first command. | | `upload(Path...)` | none | Local files to copy to the host before running (see below). | | `onStdout(Consumer)` / `onStderr(Consumer)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). | @@ -110,12 +110,36 @@ Each callback receives the output chunk by chunk as the server delivers it (not lines), on an internal worker thread, never concurrently. The wall-clock timeout of `execute()` applies unchanged. -## Character set +## Character encoding -By default the output character set does not need to be specified: the client detects the remote -host's active code page (one WQL query, always in `ROOT\CIMV2`) before the first command runs, and -**caches the result for the lifetime of the client** — later commands pay nothing. Set an explicit -`charset(...)` to skip the detection entirely. +The output character set never needs to be specified. The remote command shell is created with +console code page **65001**, so its output is UTF-8 whatever the remote machine's locale, and it is +decoded as such — no detection query, no per-host configuration: + +```java +// On a French Windows host: +client.command("vol").execute().stdout(); // " Le numéro de série du volume est …" +``` + +A handful of legacy console tools — `net.exe` and `chcp.com` are the known ones — ignore the +console code page and write their text pre-converted to the machine's **OEM** code page. Their +accented characters are not valid UTF-8 and arrive as `U+FFFD`; decode those commands with the +matching OEM charset instead: + +```java +client.command("net user Administrateur") + .charset(Charset.forName("IBM850")) // French/Western European OEM code page + .execute(); +``` + +`charset(...)` is also what you want for a command that repoints the console itself (a leading +`chcp`) or writes raw bytes in a known encoding to its standard output. WQL results are unaffected +by all of this: they travel as UTF-8 inside the SOAP envelope. + +> **Changed in 2.0.00** — earlier versions ran a `SELECT CodeSet FROM Win32_OperatingSystem` query +> before the first command and decoded the output with the code page it reported. That property is +> the remote machine's *ANSI* code page, which never matched what the shell emitted, so non-ASCII +> command output was mangled on every non-English host. ## Copying local files to the host diff --git a/src/site/markdown/timeouts-and-errors.md b/src/site/markdown/timeouts-and-errors.md index bb4e739..a4d2158 100644 --- a/src/site/markdown/timeouts-and-errors.md +++ b/src/site/markdown/timeouts-and-errors.md @@ -24,8 +24,8 @@ try (WinRMClient client = WinRMClient.builder("server.example.com") ``` The timeout is a **wall-clock deadline for the whole operation**: it covers authentication (on the -first operation), every WSMan round trip, and — for commands — the file uploads and the -remote-encoding detection, all budgeted against the same deadline. When it elapses, the operation +first operation), every WSMan round trip, and — for commands — the file uploads, all budgeted +against the same deadline. When it elapses, the operation fails with [`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html) and no part of it (in particular: the command itself) runs afterward. diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index 3299288..9353732 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -36,6 +36,7 @@ import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse; import static org.metricshub.winrm.light.FakeWsmanResponses.stream; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; @@ -239,11 +240,8 @@ void commandReturnsTypedResult() throws Exception { } @Test - void commandDetectsTheOutputCharsetOnceAndCachesIt() throws Exception { + void commandDecodesOutputAsUtf8WithoutProbingTheRemoteCodeSet() throws Exception { server - // First command: the client detects the remote code set with one WQL query... - .enqueue(200, envelope(enumerationDone(instance("Win32_OperatingSystem", "CodeSet", "1252")))) - // ...then runs the command in a fresh shell. .enqueue(200, envelope(resourceCreated("SHELL-1"))) .enqueue(200, envelope(commandResponse("CMD-1"))) .enqueue( @@ -251,7 +249,7 @@ void commandDetectsTheOutputCharsetOnceAndCachesIt() throws Exception { envelope(receiveResponse(stream("stdout", "CMD-1", "first".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) ) .enqueue(200, envelope(signalResponse())) - // Second command: no second WQL — the charset is cached, and the shell is reused. + // Second command: the shell is reused, and still no WQL round trip. .enqueue(200, envelope(commandResponse("CMD-2"))) .enqueue( 200, @@ -266,14 +264,46 @@ void commandDetectsTheOutputCharsetOnceAndCachesIt() throws Exception { assertEquals("second", client.command("second.exe").execute().stdout()); } + // The shell is created with code page 65001, so its output charset is known up front: no + // SELECT CodeSet FROM Win32_OperatingSystem probe before the first command (#142). final List requests = server.decryptedRequests(); - final long codeSetQueries = requests.stream().filter(r -> r.contains("SELECT CodeSet FROM Win32_OperatingSystem")) - .count(); - assertEquals(1, codeSetQueries, () -> String.join("\n---\n", requests)); + assertEquals( + 0, + requests.stream().filter(r -> r.contains("Win32_OperatingSystem")).count(), + () -> String.join("\n---\n", requests) + ); final long shellCreations = requests.stream().filter(r -> r.contains("")).count(); assertEquals(1, shellCreations, "the second command must reuse the shell"); } + @Test + void commandOutputKeepsNonAsciiCharactersOfEveryLocale() throws Exception { + // What a French or Japanese host actually sends back through a 65001 shell. Neither line + // survives a single-byte OEM code page: CP437 has no 番, and decoding its bytes as the ANSI + // code page turned "numéro" into "num‚ro" (#142). + final String output = "Le numéro de série du volume est E6B6-D774\r\nボリューム シリアル番号\r\n"; + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", "CMD-1", output.getBytes(StandardCharsets.UTF_8)) + + stream("stderr", "CMD-1", "Accès refusé".getBytes(StandardCharsets.UTF_8)), + done("CMD-1", 0) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + final CommandResult result = client.command("dir /A").execute(); + assertEquals(output, result.stdout()); + assertEquals("Accès refusé", result.stderr()); + } + } + @Test void wsmanFaultSurfacesAsTypedException() throws Exception { server.enqueue( @@ -516,30 +546,23 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception { } @Test - void charsetDetectionQueriesCimv2EvenWithACustomDefaultNamespace() throws Exception { + void explicitCharsetOverridesTheShellDefault() throws Exception { + // charset() is the escape hatch for the handful of legacy tools that write pre-converted OEM + // bytes instead of honoring the console code page — net.exe is the notorious one, and this is + // exactly what it sends on a French host (0x82 is "é" in CP850, invalid as UTF-8). + final Charset oem = Charset.forName("IBM850"); + final byte[] output = "Code du pays ou de la région".getBytes(oem); server - .enqueue(200, envelope(enumerationDone(instance("Win32_OperatingSystem", "CodeSet", "1252")))) .enqueue(200, envelope(resourceCreated("SHELL-1"))) .enqueue(200, envelope(commandResponse("CMD-1"))) - .enqueue( - 200, - envelope(receiveResponse(stream("stdout", "CMD-1", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) - ) + .enqueue(200, envelope(receiveResponse(stream("stdout", "CMD-1", output), done("CMD-1", 0)))) .enqueue(200, envelope(signalResponse())); - // The client's default namespace points at a custom one, where Win32_OperatingSystem does - // not exist: the internal encoding-detection query must still target ROOT\CIMV2. - try (WinRMClient client = builder(PASSWORD).namespace("root\\custom").build()) { - assertEquals("ok", client.command("whoami").execute().stdout()); - } + try (WinRMClient client = builder(PASSWORD).build()) { + final CommandResult result = client.command("net user Administrateur").charset(oem).execute(); - final String codeSetQuery = server - .decryptedRequests() - .stream() - .filter(r -> r.contains("SELECT CodeSet FROM Win32_OperatingSystem")) - .findFirst() - .orElseThrow(); - assertTrue(codeSetQuery.contains("http://schemas.microsoft.com/wbem/wsman/1/wmi/ROOT/CIMV2/*"), codeSetQuery); + assertEquals("Code du pays ou de la région", result.stdout()); + } } @Test diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 24e35dd..016f9b7 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -25,15 +25,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueCommandExchange; -import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueEnumeration; import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellCreation; import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; -import static org.metricshub.winrm.light.FakeWsmanResponses.instance; import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.net.ConnectException; -import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.List; @@ -257,17 +254,14 @@ void honorsAnAmbientInsecureTlsProperty() throws Exception { } @Test - void decodesCommandOutputUsingTheRemoteWindowsCodePage() throws Exception { - final Charset windowsCharset = Charset.forName("windows-1251"); - - // Full stack against the in-process WSMan server, through the CLI's real connect factory - // and its streaming forwarders: the remote reports Windows code page 1251 and the command - // output arrives in that encoding — the CLI must query the code page and decode the stream - // bytes with it, or the Cyrillic output turns into mojibake. + void decodesCommandOutputAsUtf8WhateverTheRemoteLocale() throws Exception { + // Full stack against the in-process WSMan server, through the CLI's real connect factory and + // its streaming forwarders: the remote shell is created with code page 65001, so its output + // arrives as UTF-8 and needs no code-page probe. Cyrillic and French accents must survive + // verbatim — decoding them as a single-byte code page is what produced mojibake (#142). try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { - enqueueEnumeration(server, instance("Win32_OperatingSystem", "CodeSet", "1251")); enqueueShellCreation(server); - enqueueCommandExchange(server, "Результат".getBytes(windowsCharset), new byte[0], 0); + enqueueCommandExchange(server, "Результат : numéro".getBytes(StandardCharsets.UTF_8), new byte[0], 0); enqueueShellDeletion(server); final Invocation invocation = invoke( @@ -290,15 +284,18 @@ void decodesCommandOutputUsingTheRemoteWindowsCodePage() throws Exception { ); assertEquals(0, invocation.exitCode); - assertEquals("Результат", invocation.stdout); + assertEquals("Результат : numéro", invocation.stdout); assertEquals("", invocation.stderr); - // The decoding charset really came from the remote code-page query + // No code-page probe: the shell's Create request pins 65001, so the charset is known. + final List requests = server.decryptedRequests(); + assertTrue( + requests.stream().noneMatch(request -> request.contains("Win32_OperatingSystem")), + () -> String.join("\n---\n", requests) + ); assertTrue( - server - .decryptedRequests() - .stream() - .anyMatch(request -> request.contains("SELECT CodeSet FROM Win32_OperatingSystem")) + requests.get(0).contains("65001"), + requests.get(0) ); } } diff --git a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java index 2852806..4f8af39 100644 --- a/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java +++ b/src/test/java/org/metricshub/winrm/command/WinRMCommandExecutorTest.java @@ -192,7 +192,6 @@ void testExecuteWithoutFilesToCopy() throws Exception { fileListVariants.add(singletonList(" \r\t\n ")); for (final List localFileToCopyList : fileListVariants) { - enqueueCodeSetQuery(server, "65001"); enqueueShellCreation(server); enqueueCommandExchange(server, "stdout".getBytes(UTF_8), "stderr".getBytes(UTF_8), 0); enqueueShellDeletion(server); @@ -217,9 +216,9 @@ void testExecuteWithoutFilesToCopy() throws Exception { } final List requests = server.decryptedRequests(); - // Each execution queried the remote code page, ran the command verbatim (no CMD.EXE /C - // wrapper on the no-copy path), and deleted its shell on close - assertEquals(3, count(requests, "SELECT CodeSet FROM Win32_OperatingSystem")); + // Each execution ran the command verbatim (no CMD.EXE /C wrapper on the no-copy path) and + // deleted its shell on close, without probing the remote code page (#142) + assertEquals(0, count(requests, "SELECT CodeSet FROM Win32_OperatingSystem")); assertEquals(3, count(requests, "" + COMMAND + "")); assertEquals(3, count(requests, "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete")); } @@ -249,8 +248,7 @@ void testExecuteWithFileToCopy() throws Exception { enqueueCommandExchange(server, hashOutput.getBytes(UTF_8), NO_OUTPUT, 0); // 5: publish (MOVE) + digest probe of the destination enqueueCommandExchange(server, hashOutput.getBytes(UTF_8), NO_OUTPUT, 0); - // then the code-page query and the actual command - enqueueCodeSetQuery(server, "65001"); + // then the actual command enqueueCommandExchange(server, "stdout".getBytes(UTF_8), "stderr".getBytes(UTF_8), 0); enqueueShellDeletion(server); @@ -293,10 +291,6 @@ void testExecuteWithFileToCopy() throws Exception { } } - private static void enqueueCodeSetQuery(final FakeWsmanServer server, final String codeSet) { - enqueueEnumeration(server, instance("Win32_OperatingSystem", "CodeSet", codeSet)); - } - private static long count(final List requests, final String needle) { return requests.stream().filter(request -> request.contains(needle)).count(); } diff --git a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java index f7c62fe..8459c31 100644 --- a/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java +++ b/src/test/java/org/metricshub/winrm/light/WsmanProtocolTest.java @@ -253,7 +253,9 @@ void commandLifecycleReassemblesMultibyteOutputSplitAcrossReceives() throws Exce assertTrue(requests.size() >= 5, () -> String.join("\n---\n", requests)); final String create = requests.get(0); assertTrue(create.contains("TRUE"), create); - assertTrue(create.contains("437"), create); + // UTF-8: the only console code page that can carry every remote locale's output, and the one + // the decoding side assumes without asking the remote host (see #142). + assertTrue(create.contains("65001"), create); assertTrue(create.contains("stdout stderr"), create); final String command = requests.get(1); assertTrue(command.contains("echo héllo!"), command);