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);