From cfc4cd7a986961c69ff3df4778ca790e5c2baef3 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Tue, 29 Sep 2026 00:04:43 +0200 Subject: [PATCH] Send remote file content as raw bytes instead of base64 (#175) The read, digest and download-probe scripts now write the bytes raw to [Console]::OpenStandardOutput(), 64 KiB per write, and the client takes stdout as undecoded chunks through a package-private RemoteProcess.readStdoutChunk(). The base64 line decoder is gone. Raw output was verified byte-exact on Windows Server 2008 R2 (PowerShell 2.0), 2019 and 2022: every byte value, files ending in CR or LF, a UTF-8 BOM file, an empty file, and ranges across a block boundary, through every read terminal and against a SHA-256 computed on the host. PowerShell 2.0 adds no byte order mark to the raw stream. A 20 MiB openStream() now takes 10.6-12 s instead of 14-17 s (about 1.3x faster) on all three hosts. The shorter read script also raises the path limit of a read from about 1,450 to about 1,500 characters. Co-Authored-By: Claude Opus 5.5 --- .../java/org/metricshub/winrm/RemoteFile.java | 21 ++-- .../org/metricshub/winrm/RemoteFiles.java | 113 +++++++----------- .../org/metricshub/winrm/RemoteProcess.java | 31 ++++- src/site/markdown/cli.md | 6 +- src/site/markdown/file-transfers.md | 8 +- src/site/markdown/files.md | 37 +++--- .../org/metricshub/winrm/RemoteFileTest.java | 109 ++++++++--------- .../winrm/RemoteFilesScriptTest.java | 54 ++++----- .../metricshub/winrm/cli/WinRmCliTest.java | 45 +++---- 9 files changed, 209 insertions(+), 215 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/RemoteFile.java b/src/main/java/org/metricshub/winrm/RemoteFile.java index 8a39f15..a5d0e88 100644 --- a/src/main/java/org/metricshub/winrm/RemoteFile.java +++ b/src/main/java/org/metricshub/winrm/RemoteFile.java @@ -72,15 +72,16 @@ * } *

* The content travels through the WinRM connection itself (no SMB, no extra port): a small - * PowerShell script on the host writes the bytes base64-encoded, which is binary-safe and - * independent of the remote console code page. It is designed for configuration files, logs and - * small data files — not a bulk transport: base64 through a command shell is far slower - * than SMB. The host needs PowerShell (2.0 or later) in {@code FullLanguage} mode. + * PowerShell script on the host writes the file's raw bytes to its standard output stream, past + * any text conversion, so every byte arrives as stored whatever the remote console code page. It + * is designed for configuration files, logs and small data files — not a bulk transport: + * the WinRM service forwards a command's output at about 2 MB/s, far slower than SMB. The host + * needs PowerShell (2.0 or later) in {@code FullLanguage} mode. *

* The file is opened with a share mode that tolerates other writers, so a log being written by a * running service can be read; a file held with an exclusive lock (e.g. {@code pagefile.sys}) * fails with a sharing violation. A read writes nothing on the host: its script travels on the - * command line, which limits the path to about 1,450 characters (fewer with non-Latin + * command line, which limits the path to about 1,500 characters (fewer with non-Latin * characters) — a longer path fails before anything is sent. Ranges are byte ranges, and * reads are not snapshots: a * file that grows or shrinks between two reads is read as it is at each read. @@ -257,10 +258,10 @@ public String readText(final Charset charset) { } /** - * Open the content (the whole file, or the configured range) as a stream, decoded as it - * arrives: memory is bounded by one transfer block, not by the file, and there is no size cap. - * The file is opened before this method returns, so a file that cannot be read fails here; a - * failure midway is reported by {@code read()} (never as a silently short read). + * Open the content (the whole file, or the configured range) as a stream, passed on as it + * arrives: memory is bounded by one protocol response, not by the file, and there is no size + * cap. The file is opened before this method returns, so a file that cannot be read fails here; + * a failure midway is reported by {@code read()} (never as a silently short read). *

* The stream must be closed — use try-with-resources. It holds the client's connection * until it reaches its end or is closed; closing it early stops the remote read. Failures are @@ -314,7 +315,7 @@ public BufferedReader openReader(final Charset charset) { * {@link #maxBytes(long)} settings do not apply. *

* The timeout is a wall-clock deadline for the whole download, and the transfer runs at about - * 1.5 MB/s: a large file needs a raised {@link #timeout(Duration)}. A deadline that fires while + * 1.8 MB/s: a large file needs a raised {@link #timeout(Duration)}. A deadline that fires while * the verified file is being moved onto the destination lets that move complete: the download * then succeeds. Downloads are not resumable — one that fails or times out starts over. * diff --git a/src/main/java/org/metricshub/winrm/RemoteFiles.java b/src/main/java/org/metricshub/winrm/RemoteFiles.java index 9762ac7..8e143a6 100644 --- a/src/main/java/org/metricshub/winrm/RemoteFiles.java +++ b/src/main/java/org/metricshub/winrm/RemoteFiles.java @@ -38,15 +38,17 @@ /** * The remote file access primitive: small PowerShell scripts that open a remote file and write - * what the caller asked for as base64 lines on stdout, and the {@link InputStream} that - * decodes those lines as the output chunks arrive. The metadata scripts ({@link #infoScript}, - * {@link #listScript}) write one ASCII record per entry instead, parsed by {@link #parseEntry} - * and {@link #parseInaccessible}: plain integers, and only the path base64-encoded. + * what the caller asked for (its bytes, its digest) raw on stdout, and the + * {@link InputStream} that passes those bytes on as the output chunks arrive. The metadata scripts + * ({@link #infoScript}, {@link #listScript}) write one ASCII record per entry instead, parsed by + * {@link #parseEntry} and {@link #parseInaccessible}: plain integers, and only the path + * base64-encoded. *

- * Base64 is what makes a text console a binary-safe channel: every byte value survives, and the - * output is pure ASCII, so recovering the exact bytes never depends on the remote console code - * page. Each line is an independent base64 block, decoded on its own. The caller-supplied path is - * embedded base64-encoded too (UTF-8), so no path ever needs quoting or escaping. + * The bytes are written to {@code [Console]::OpenStandardOutput()}, a stream no text writer, code + * page or byte order mark touches, and the WinRM service forwards a command's stdout as it is: + * every byte value arrives as written (verified on Windows Server 2008 R2 with PowerShell 2.0, + * 2019 and 2022). The caller-supplied path is embedded base64-encoded (UTF-8), so no path ever + * needs quoting or escaping. *

* Failures are reported by the script's exit code, checked when the output ends (see the * {@code EXIT_*} constants), and turned into a {@link WinRMClientException} naming the cause. @@ -77,14 +79,11 @@ private RemoteFiles() {} static final int EXIT_COMMAND_NOT_FOUND = 9009; /** - * Bytes read and written per base64 line: 49,149, a multiple of 3 so a full block encodes - * without padding, into 65,532 characters — with the newline, a 65,533-byte line that fits in - * exactly two of the WinRM service's output reads. The service's shell plugin reads a - * command's stdout pipe with at most one 32 KiB read per timer tick (64 per second at the - * default 15.625 ms), so each line should fill whole reads: a 57 KiB block (a 77,825-byte - * line) needs three, and is measurably slower. + * Bytes read and written per block: 64 KiB, exactly two of the WinRM service's output reads. + * The service's shell plugin reads a command's stdout pipe with at most one 32 KiB read per + * timer tick (64 per second at the default 15.625 ms), so each write should fill whole reads. */ - static final int BLOCK_SIZE = 49_149; + static final int BLOCK_SIZE = 64 * 1024; /** * The hash algorithms {@link #digestScript(String, String)} accepts: the names .NET's @@ -193,12 +192,12 @@ private RemoteFiles() {} /** * Read a byte range: resolve a negative offset from the size of the open stream (so a * growing log is tailed from its current end), seek, and write at most {@code length} bytes - * ({@code -1}: to the end) as one base64 line per block. Seeking past the end is legal and - * reads nothing. {@code %d} are the offset, the length and the block size. + * ({@code -1}: to the end), block by block. Seeking past the end is legal and reads nothing. + * {@code %d} are the offset, the length and the block size. *

- * Each line goes to the raw stdout stream as ONE write: the WinRM service reads the pipe at + * Each block goes to the raw stdout stream as ONE write: the WinRM service reads the pipe at * most once per timer tick, taking only what is in the pipe, and {@code [Console]::Out} (an - * auto-flushing writer with a small buffer) cuts a line into 256-byte writes that leave each + * auto-flushing writer with a small buffer) cuts its output into 256-byte writes that leave each * read with about 4 KiB — 5 to 6 times slower (measured on Windows 2008 R2 and 2022). */ private static final String READ_RANGE = "try{$n=[long]%d;$l=[long]%d;" + @@ -206,24 +205,23 @@ private RemoteFiles() {} "$f.Position=$n;$b=New-Object byte[] %d;$o=[Console]::OpenStandardOutput();" + "while($l -ne 0){$c=$b.Length;if($l -gt 0 -and $l -lt $c){$c=[int]$l};" + "$r=$f.Read($b,0,$c);if($r -le 0){break};" + - "$a=[Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($b,0,$r)+\"`n\");" + - "$o.Write($a,0,$a.Length);if($l -gt 0){$l-=$r}};" + + "$o.Write($b,0,$r);if($l -gt 0){$l-=$r}};" + "$o.Flush()}catch{fail $_.Exception}finally{$f.Close()}"; - /** Hash the whole file and write the raw digest as one base64 line. {@code %s} is the algorithm. */ + /** Hash the whole file and write the raw digest. {@code %s} is the algorithm. */ private static final String DIGEST = "try{$h=[Security.Cryptography.HashAlgorithm]::Create('%s');" + - "[Console]::Out.WriteLine([Convert]::ToBase64String($h.ComputeHash($f)))}catch{fail $_.Exception}finally{$f.Close()}"; + "$y=$h.ComputeHash($f);$o=[Console]::OpenStandardOutput();$o.Write($y,0,$y.Length)}" + + "catch{fail $_.Exception}finally{$f.Close()}"; /** - * What a download is verified against, as one base64 line of {@link #PROBE_LENGTH} bytes: the - * number of bytes hashed (8 bytes, little-endian), then their SHA-256 digest. The count is the - * stream position after hashing, not the file length read beforehand, so both describe the same - * bytes even when the file changes during the probe. + * What a download is verified against, {@link #PROBE_LENGTH} raw bytes: the number of bytes + * hashed (8 bytes, little-endian), then their SHA-256 digest. The count is the stream position + * after hashing, not the file length read beforehand, so both describe the same bytes even when + * the file changes during the probe. */ - private static final String PROBE = "try{$h=[Security.Cryptography.HashAlgorithm]::Create('SHA256');$d=$h.ComputeHash($f);" - + - "[Console]::Out.WriteLine([Convert]::ToBase64String([byte[]]([BitConverter]::GetBytes($f.Position)+$d)))}" + - "catch{fail $_.Exception}finally{$f.Close()}"; + private static final String PROBE = "try{$h=[Security.Cryptography.HashAlgorithm]::Create('SHA256');" + + "$d=$h.ComputeHash($f);$y=[byte[]]([BitConverter]::GetBytes($f.Position)+$d);" + + "$o=[Console]::OpenStandardOutput();$o.Write($y,0,$y.Length)}catch{fail $_.Exception}finally{$f.Close()}"; /** The length of the {@link #probeScript(String)} output: an 8-byte size and a 32-byte SHA-256 digest. */ static final int PROBE_LENGTH = 40; @@ -432,19 +430,19 @@ private static WinRMClientException malformed(final String line, final Throwable } /** - * Start the script and return the stream of the bytes it writes. The first line is fetched + * Start the script and return the stream of the bytes it writes. The first bytes are fetched * before returning, so a file that cannot be opened fails here, not on the first read. * * @param client the client to run the script on * @param path the remote file, for the error messages - * @param script the script, from {@link #readScript} or {@link #digestScript} + * @param script the script, from {@link #readScript}, {@link #digestScript} or {@link #probeScript} * @param timeout the inactivity timeout of the stream - * @return the decoded stream; it must be closed + * @return the stream; it must be closed */ static InputStream open(final WinRMClient client, final String path, final String script, final Duration timeout) { final RemoteProcess process = start(client, path, script, timeout); try { - return new DecodingStream(process, path, client.hostname()); + return new ContentStream(process, path, client.hostname()); } catch (final RuntimeException e) { process.close(); throw e; @@ -641,23 +639,21 @@ static WinRMClientException failure( } /** - * The bytes a script writes, decoded line by line as the output arrives: memory is bounded by - * one line, not by the file. The exit code is checked when the output ends, so a failure - * midway is reported instead of looking like a short file. + * The bytes a script writes, passed on chunk by chunk as the output arrives: memory is bounded + * by one protocol response, not by the file. The exit code is checked when the output ends, so + * a failure midway is reported instead of looking like a short file. */ - private static final class DecodingStream extends InputStream { + private static final class ContentStream extends InputStream { private final RemoteProcess process; - private final BufferedReader stdout; private final String path; private final String hostname; private byte[] block = new byte[0]; private int position; private boolean ended; - private DecodingStream(final RemoteProcess process, final String path, final String hostname) { + private ContentStream(final RemoteProcess process, final String path, final String hostname) { this.process = process; - this.stdout = process.stdout(); this.path = path; this.hostname = hostname; fill(); @@ -690,35 +686,16 @@ public int available() { /** Make sure unread bytes are buffered: {@code false} at the end of a successful output. */ private boolean fill() { - while (position == block.length) { - if (ended) { - return false; - } - final String line = readLine(stdout); - if (line == null) { + if (position == block.length && !ended) { + final byte[] chunk = process.readStdoutChunk(); + if (chunk == null) { end(); - return false; - } - final String data = line.strip(); - if (data.isEmpty()) { - continue; - } - try { - block = Base64.getDecoder().decode(data); - } catch (final IllegalArgumentException e) { - throw new WinRMClientException( - String.format( - "Unexpected output while reading remote file %s on %s: %s", - path, - hostname, - data.length() > 80 ? data.substring(0, 80) + "..." : data - ), - e - ); + } else { + block = chunk; + position = 0; } - position = 0; } - return true; + return position < block.length; } /** The output ended: collect the exit code, release the connection, report a failure. */ diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java index 357449c..6d8eba4 100644 --- a/src/main/java/org/metricshub/winrm/RemoteProcess.java +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -29,6 +29,7 @@ import java.io.Writer; import java.nio.charset.Charset; import java.time.Duration; +import java.util.ArrayDeque; import java.util.concurrent.TimeoutException; import org.metricshub.winrm.exceptions.WinRMTimeoutException; import org.metricshub.winrm.exceptions.WindowsRemoteException; @@ -98,6 +99,10 @@ public final class RemoteProcess implements AutoCloseable { private final StringBuilder stdoutPending = new StringBuilder(); private final StringBuilder stderrPending = new StringBuilder(); + // Undecoded stdout chunks not read yet, once readStdoutChunk() took stdout as raw bytes: null + // while stdout is decoded text. + private ArrayDeque stdoutChunks; + // Written input that has not been flushed to the host yet. private final StringBuilder stdinPending = new StringBuilder(); @@ -166,6 +171,26 @@ public BufferedReader stderr() { return stderr; } + /** + * Read the next chunk of the standard output as raw bytes, bypassing the charset decoder: for + * output that is not text, like the content of a remote file (see {@link RemoteFiles}). The + * first call switches stdout to raw bytes for good, so make it before anything reads or waits + * for output; the {@link #stdout()} reader then stays empty. + * + * @return the bytes as they arrived, never empty, or {@code null} at the end of the output + * @throws WinRMTimeoutException when the command stays silent for a whole inactivity timeout + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + synchronized byte[] readStdoutChunk() { + if (stdoutChunks == null) { + stdoutChunks = new ArrayDeque<>(); + } + while (stdoutChunks.isEmpty() && !finished) { + fetchOnce(); + } + return stdoutChunks.poll(); + } + /** * 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 @@ -410,7 +435,11 @@ private void absorb(final CommandCursor.Chunk chunk) { stdoutPending.append(stdoutDecoder.finish()); stderrPending.append(stderrDecoder.finish()); } else { - stdoutPending.append(stdoutDecoder.decode(chunk.stdout())); + if (stdoutChunks == null) { + stdoutPending.append(stdoutDecoder.decode(chunk.stdout())); + } else if (chunk.stdout().length > 0) { + stdoutChunks.add(chunk.stdout()); + } stderrPending.append(stderrDecoder.decode(chunk.stderr())); } } diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 6c32d4b..2ca8397 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -310,7 +310,7 @@ there, or run the redirection in cmd.exe. PowerShell 7.4 and later keep the byte When the output is closed early — `... cat 'D:\logs\huge.log' | head` — `cat` stops the remote read instead of transferring the rest of the file for nobody, and exits with `74`. The transfer -runs at about 1.5 MB/s (see [Read performance](files.html#read-performance)): logs and +runs at about 2 MB/s (see [Read performance](files.html#read-performance)): logs and configuration files, not bulk data. ### `get` @@ -319,8 +319,8 @@ configuration files, not bulk data. [digest-verified, atomic, and skipped when the local copy is already identical](file-transfers.html#downloading-a-file). Without a local path, the file is written in the current directory under its remote name; an existing directory receives it under its remote name too. Nothing is printed on success. -`--timeout` is the deadline of the whole download: at about 1.5 MB/s, the default 60 seconds -covers files up to about 80 MB — raise it for larger ones. +`--timeout` is the deadline of the whole download: at about 1.8 MB/s, the default 60 seconds +covers files up to about 100 MB — raise it for larger ones. ### Quoting remote paths diff --git a/src/site/markdown/file-transfers.md b/src/site/markdown/file-transfers.md index 27567c3..d614f1d 100644 --- a/src/site/markdown/file-transfers.md +++ b/src/site/markdown/file-transfers.md @@ -243,7 +243,7 @@ reads need — PowerShell 2.0 or later in `FullLanguage` mode, and read access t The timeout of a download is a **wall-clock deadline for the whole transfer**: the client's timeout for `downloadFile(...)` (30 seconds by default), or `timeout(Duration)` on the request. -A large file needs a raised timeout — at the speed below, 30 seconds is about 40 MB. When the +A large file needs a raised timeout — at the speed below, 30 seconds is about 50 MB. When the deadline fires, the exception says how far the transfer got: ```text @@ -257,9 +257,9 @@ place lets the move complete, and the download succeeds. ### Download performance -Measured over HTTP with NTLM encryption, a download runs at about **1.35–1.45 MB/s**: 20 MiB in -14.7 seconds on Windows Server 2022, 15.6 seconds on Windows Server 2008 R2 (PowerShell 2.0), and -64 MiB in 46 seconds on 2022 — probe included. A small file costs under a second (two PowerShell +Measured over HTTP with NTLM encryption, a download runs at about **1.7–1.9 MB/s**: 20 MiB in +12 seconds on Windows Server 2022 and 2008 R2 (PowerShell 2.0), 13 seconds on 2019, and 64 MiB in +35 seconds on 2022 — probe included. A small file costs under a second (two PowerShell invocations: the probe and the read), and skipping an identical copy about 0.4 seconds. The limit is on the host, in the way the WinRM service forwards a command's output — see [Read performance](files.html#read-performance). diff --git a/src/site/markdown/files.md b/src/site/markdown/files.md index 4113fea..ec2272a 100644 --- a/src/site/markdown/files.md +++ b/src/site/markdown/files.md @@ -7,7 +7,7 @@ description: How the WinRM Java Client reads files and lists directories on the WinRM has no file-access operation of its own (nothing like SFTP's `READ`), so the client reads remote files and lists directories **through the WinRM command shell**: a small PowerShell script -does the work on the host and writes the result in an encoding-proof form, and the client decodes +does the work on the host and writes the result in an encoding-proof form, and the client reads it as it arrives. No SMB, no extra port, no share. This is the reverse direction of [File Transfers](file-transfers.html). The standalone jar exposes it as the `ls`, `stat`, `cat`, and `get` subcommands — see the [Command-Line Client](cli.html#remote-files) manual. @@ -70,11 +70,11 @@ try (BufferedReader reader = client.file("D:\\logs\\huge.log").openReader(Standa } ``` -The stream decodes the content block by block as it arrives (48 KiB per block): memory stays -bounded whatever the file size. Like a [`RemoteProcess`](commands.html), **it must be closed** — -it holds the client's connection until it reaches its end or is closed, and closing it early stops -the remote read. The file is opened before `openStream()` returns, so a missing file fails there; -a failure midway is reported by `read()`, never as a silently short read. +The stream passes the content on as it arrives: memory stays bounded whatever the file size. +Like a [`RemoteProcess`](commands.html), **it must be closed** — it holds the client's connection +until it reaches its end or is closed, and closing it early stops the remote read. The file is +opened before `openStream()` returns, so a missing file fails there; a failure midway is reported +by `read()`, never as a silently short read. ## Downloading to a local file @@ -115,10 +115,12 @@ operation. See [Timeouts and Errors](timeouts-and-errors.html). * The host needs **PowerShell 2.0 or later in `FullLanguage` mode** (Windows Server 2008 R2 and later ship it). When AppLocker or WDAC puts PowerShell in Constrained Language Mode, the .NET calls the scripts rely on are blocked: remote file access is then not available. -* Non-ASCII paths and content are safe: the path travels base64-encoded (UTF-8) inside the script - and the content comes back base64-encoded, so neither depends on the remote console code page. +* Non-ASCII paths and binary content are safe: the path travels base64-encoded (UTF-8) inside the + script, and the content comes back as the file's raw bytes, written straight to the standard + output stream, past any text conversion. Neither depends on the remote console code page: every + byte value arrives as stored, verified on Windows Server 2008 R2 (PowerShell 2.0), 2019 and 2022. * Remote file access never writes anything on the host: its scripts travel on the command line, - which limits the path to about **1,450 characters** for a read, **1,150** for `info()`, and + which limits the path to about **1,500 characters** for a read, **1,150** for `info()`, and **450** for `list()`, whose walker script is the largest (roughly half as many with accented letters, a third with CJK characters) — all above the classic 260-character `MAX_PATH`. A longer path fails with a `WinRMClientException` before anything is sent. @@ -126,17 +128,18 @@ operation. See [Timeouts and Errors](timeouts-and-errors.html). ## Read performance The mechanism is designed for **configuration files, logs and small data files — not bulk data**. -Measured over HTTP with NTLM encryption, `openStream()` reads about **1.4–1.5 MB/s**: a 20 MiB -file in 14–15 seconds, on Windows Server 2008 R2 (PowerShell 2.0), 2019 and 2022 (PowerShell 5.1) -alike. A small file costs about a second, mostly the PowerShell startup. For gigabytes, use SMB. +Measured over HTTP with NTLM encryption, `openStream()` reads about **1.8–2.0 MB/s**: a 20 MiB +file in 10.6–12 seconds, on Windows Server 2008 R2 (PowerShell 2.0), 2019 and 2022 +(PowerShell 5.1) alike. A small file costs about a second, mostly the PowerShell startup. For +gigabytes, use SMB. The limit is on the host, not in the network or the client: the WinRM service reads a command's output pipe itself, **at most 32 KiB per read and about 60 reads per second** per output stream — -about 2 MB/s of output whatever the command. The reader is shaped for it: each block of the file -travels as one 65,533-byte base64 line that fills exactly two reads (a larger 77,825-byte line -needing three reads was 20–25% slower). Neither the client's `MaxEnvelopeSize` nor the host's -`MaxEnvelopeSizekb` changes the limit — raising them only makes each response larger and -proportionally slower to come. +about 2 MB/s of output whatever the command. The reader runs at that limit: the host writes the +file's raw bytes 64 KiB at a time, so every read is a full one and carries nothing but content +(base64 text would carry only 3 bytes of the file in 4, and measured a third slower). Neither the +client's `MaxEnvelopeSize` nor the host's `MaxEnvelopeSizekb` changes the limit — raising them +only makes each response larger and proportionally slower to come. The same limit applies to any command output: a command that writes its output in small pieces — line by line, like `Write-Output` or `type` — gets only what accumulated in the 4 KiB pipe at each diff --git a/src/test/java/org/metricshub/winrm/RemoteFileTest.java b/src/test/java/org/metricshub/winrm/RemoteFileTest.java index 19b1fea..f815054 100644 --- a/src/test/java/org/metricshub/winrm/RemoteFileTest.java +++ b/src/test/java/org/metricshub/winrm/RemoteFileTest.java @@ -64,9 +64,9 @@ import org.metricshub.winrm.light.FakeWsmanServer; /** - * Client-side tests of {@link RemoteFile} against {@link FakeWsmanServer}: the scripted - * base64-line output is decoded incrementally, exit codes become the documented exceptions, and - * the request settings reach the script. The scripts themselves run in + * Client-side tests of {@link RemoteFile} against {@link FakeWsmanServer}: the scripted raw + * output is passed on as it arrives, exit codes become the documented exceptions, and the request + * settings reach the script. The scripts themselves run in * {@link RemoteFilesScriptTest}. */ class RemoteFileTest { @@ -102,18 +102,22 @@ private static String b64(final byte[] bytes) { return Base64.getEncoder().encodeToString(bytes); } - private static String stdout(final String text) { - return stream("stdout", COMMAND_ID, text.getBytes(StandardCharsets.US_ASCII)); + private static String stdout(final byte[] bytes) { + return stream("stdout", COMMAND_ID, bytes); + } + + private static byte[] ascii(final String text) { + return text.getBytes(StandardCharsets.US_ASCII); } /** Script a whole read: shell creation, command, the given Receive chunks (the last one completes), Signal. */ - private void enqueueRead(final int exitCode, final String stderr, final String... chunks) { + private void enqueueRead(final int exitCode, final String stderr, final byte[]... chunks) { server.enqueue(200, envelope(resourceCreated("SHELL-1"))); enqueueCommand(exitCode, stderr, chunks); } /** Script a command on the existing shell: command, the given Receive chunks (the last one completes), Signal. */ - private void enqueueCommand(final int exitCode, final String stderr, final String... chunks) { + private void enqueueCommand(final int exitCode, final String stderr, final byte[]... chunks) { server.enqueue(200, envelope(commandResponse(COMMAND_ID))); for (int i = 0; i < chunks.length; i++) { final boolean last = i == chunks.length - 1; @@ -144,21 +148,18 @@ private List sentScripts() { } /** The probe output announcing the given content: its size (8 bytes, little-endian), then its SHA-256 digest. */ - private static String probe(final byte[] content) throws Exception { + private static byte[] probe(final byte[] content) throws Exception { return probe(content.length, content); } /** The probe output announcing the given size and the SHA-256 digest of the given content. */ - private static String probe(final long size, final byte[] content) throws Exception { - return b64( - ByteBuffer - .allocate(RemoteFiles.PROBE_LENGTH) - .order(ByteOrder.LITTLE_ENDIAN) - .putLong(size) - .put(MessageDigest.getInstance("SHA-256").digest(content)) - .array() - ) + - "\r\n"; + private static byte[] probe(final long size, final byte[] content) throws Exception { + return ByteBuffer + .allocate(RemoteFiles.PROBE_LENGTH) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(size) + .put(MessageDigest.getInstance("SHA-256").digest(content)) + .array(); } /** The files in the directory, sorted. */ @@ -169,15 +170,14 @@ private static List files(final Path directory) throws IOException { } @Test - void readBytesDecodesLinesSplitAcrossReceiveChunks() { + void readBytesJoinsTheChunksWhereverTheyAreCut() { final byte[] content = new byte[300]; for (int i = 0; i < content.length; i++) { content[i] = (byte) i; } - final String line1 = b64(Arrays.copyOfRange(content, 0, 150)); - final String line2 = b64(Arrays.copyOfRange(content, 150, 300)); - // The first line is cut in the middle by the protocol chunking. - enqueueRead(0, null, line1.substring(0, 17), line1.substring(17) + "\r\n" + line2 + "\r\n"); + // One block written by the host, cut in the middle by the protocol chunking, with an empty + // Receive in between. + enqueueRead(0, null, Arrays.copyOfRange(content, 0, 17), new byte[0], Arrays.copyOfRange(content, 17, 300)); try (WinRMClient client = client()) { assertArrayEquals(content, client.file(PATH).readBytes()); @@ -191,7 +191,7 @@ void readBytesDecodesLinesSplitAcrossReceiveChunks() { @Test void rangeSettingsReachTheScript() { - enqueueRead(0, null, b64("tail".getBytes(StandardCharsets.US_ASCII)) + "\r\n"); + enqueueRead(0, null, ascii("tail")); try (WinRMClient client = client()) { assertEquals("tail", client.file(PATH).offset(-8192).length(1024).readText(StandardCharsets.US_ASCII)); } @@ -200,7 +200,7 @@ void rangeSettingsReachTheScript() { @Test void openStreamHasNoCapAndReadsToTheEnd() throws Exception { - enqueueRead(0, null, b64(new byte[] { 1, 2, 3 }) + "\r\n", b64(new byte[] { 4, 5 }) + "\r\n"); + enqueueRead(0, null, new byte[] { 1, 2, 3 }, new byte[] { 4, 5 }); try (WinRMClient client = client(); InputStream in = client.file(PATH).openStream()) { assertArrayEquals(new byte[] { 1, 2, 3, 4, 5 }, in.readAllBytes()); } @@ -208,17 +208,16 @@ void openStreamHasNoCapAndReadsToTheEnd() throws Exception { } @Test - void readTextKeepsTheBomAndOpenReaderDecodesACharacterSplitAcrossLines() throws Exception { - final byte[] bom = { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF }; - enqueueRead(0, null, b64(bom) + "\r\n" + b64("é".getBytes(StandardCharsets.UTF_8)) + "\r\n"); + void readTextKeepsTheBomAndOpenReaderDecodesACharacterSplitAcrossChunks() throws Exception { + enqueueRead(0, null, new byte[] { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF, (byte) 0xC3, (byte) 0xA9 }); try (WinRMClient client = client()) { assertEquals("\uFEFFé", client.file(PATH).readText(StandardCharsets.UTF_8)); } - // "é" is C3 A9: its two bytes arrive on two separate lines (two transfer blocks). + // "é" is C3 A9: its two bytes arrive in two separate chunks. server.close(); server = new FakeWsmanServer(DOMAIN, USER, PASSWORD); - enqueueRead(0, null, b64(new byte[] { 'a', (byte) 0xC3 }) + "\r\n", b64(new byte[] { (byte) 0xA9, 'b' }) + "\r\n"); + enqueueRead(0, null, new byte[] { 'a', (byte) 0xC3 }, new byte[] { (byte) 0xA9, 'b' }); try (WinRMClient client = client(); BufferedReader reader = client.file(PATH).openReader(StandardCharsets.UTF_8)) { assertEquals("aéb", reader.readLine()); } @@ -226,7 +225,7 @@ void readTextKeepsTheBomAndOpenReaderDecodesACharacterSplitAcrossLines() throws @Test void theSizeCapIsSentToTheHostAndEnforced() { - enqueueRead(0, null, b64(new byte[] { 1, 2, 3, 4, 5 }) + "\r\n"); + enqueueRead(0, null, new byte[] { 1, 2, 3, 4, 5 }); try (WinRMClient client = client()) { final WinRMClientException e = assertThrows( WinRMClientException.class, @@ -239,7 +238,7 @@ void theSizeCapIsSentToTheHostAndEnforced() { @Test void aMissingFileFailsWhenTheStreamIsOpened() { - enqueueRead(RemoteFiles.EXIT_NOT_FOUND, "Could not find file", ""); + enqueueRead(RemoteFiles.EXIT_NOT_FOUND, "Could not find file", new byte[0]); try (WinRMClient client = client()) { final RemoteFile file = client.file(PATH); final WinRMClientException e = assertThrows(WinRMClientException.class, file::openStream); @@ -262,7 +261,7 @@ void exitCodesBecomeExplicitFailures() { void anExactLengthReadStillChecksTheExitCode() { // The host sends exactly the requested bytes, then fails (e.g. while closing the file): // readBytes() must drain to the end of the output instead of returning a full buffer. - enqueueRead(1, "The device is not ready", b64(new byte[] { 1, 2, 3 }) + "\r\n"); + enqueueRead(1, "The device is not ready", new byte[] { 1, 2, 3 }); try (WinRMClient client = client()) { final RemoteFile file = client.file(PATH).length(3); final WinRMClientException e = assertThrows(WinRMClientException.class, file::readBytes); @@ -273,18 +272,18 @@ void anExactLengthReadStillChecksTheExitCode() { @Test void aPathTooLongForTheCommandLineIsRefusedBeforeAnythingIsSent() { try (WinRMClient client = client()) { - final RemoteFile file = client.file("C:\\" + "a".repeat(1500)); + final RemoteFile file = client.file("C:\\" + "a".repeat(1550)); final WinRMClientException e = assertThrows(WinRMClientException.class, file::readBytes); assertTrue(e.getMessage().contains("too long"), e.getMessage()); // Just under the limit still fits: the script is not uploaded as a file. - assertTrue(CommandRequest.encodePowerShell(RemoteFiles.readScript("C:\\" + "a".repeat(1450), -8192, 1)) != null); + assertTrue(CommandRequest.encodePowerShell(RemoteFiles.readScript("C:\\" + "a".repeat(1500), -8192, 1)) != null); } assertEquals(0, server.decryptedRequests().size()); } @Test void aFailureMidwayIsNotASilentlyShortRead() throws Exception { - enqueueRead(1, "The device is not ready", b64(new byte[] { 1, 2, 3 }) + "\r\n"); + enqueueRead(1, "The device is not ready", new byte[] { 1, 2, 3 }); try (WinRMClient client = client(); InputStream in = client.file(PATH).openStream()) { assertEquals(1, in.read()); final WinRMClientException e = assertThrows(WinRMClientException.class, in::readAllBytes); @@ -292,18 +291,9 @@ void aFailureMidwayIsNotASilentlyShortRead() throws Exception { } } - @Test - void unexpectedOutputIsReported() { - enqueueRead(0, null, "WARNING: not base64!\r\n"); - try (WinRMClient client = client()) { - final WinRMClientException e = assertThrows(WinRMClientException.class, () -> client.file(PATH).readBytes()); - assertTrue(e.getMessage().contains("Unexpected output"), e.getMessage()); - } - } - @Test void digestIsLowercaseHex() { - enqueueRead(0, null, b64(new byte[] { (byte) 0xAB, 0x01, (byte) 0xFF }) + "\r\n"); + enqueueRead(0, null, new byte[] { (byte) 0xAB, 0x01, (byte) 0xFF }); try (WinRMClient client = client()) { assertEquals("ab01ff", client.file(PATH).digest("SHA256")); } @@ -343,7 +333,7 @@ void aStartRejectedByTheOperationQuotaIsRetried() { 500, fault("2150859174", "The maximum number of concurrent operations for this user has been exceeded.") ); - enqueueCommand(0, null, b64("ok".getBytes(StandardCharsets.US_ASCII)) + "\r\n"); + enqueueCommand(0, null, ascii("ok")); try (WinRMClient client = client()) { assertEquals("ok", client.file(PATH).readText(StandardCharsets.US_ASCII)); } @@ -374,7 +364,7 @@ void downloadReplacesADifferentLocalFile(@TempDir final Path directory) throws E final byte[] content = "the new content".getBytes(StandardCharsets.US_ASCII); final Path local = Files.write(directory.resolve("copy.bin"), "previous".getBytes(StandardCharsets.US_ASCII)); enqueueRead(0, null, probe(content)); - enqueueCommand(0, null, b64(content) + "\r\n"); + enqueueCommand(0, null, content); try (WinRMClient client = client()) { assertEquals(content.length, client.downloadFile(PATH, local)); } @@ -390,7 +380,7 @@ void aReplacedFileKeepsItsPosixPermissions(@TempDir final Path directory) throws Files.setPosixFilePermissions(local, ownerOnly); final byte[] content = "password=new".getBytes(StandardCharsets.US_ASCII); enqueueRead(0, null, probe(content)); - enqueueCommand(0, null, b64(content) + "\r\n"); + enqueueCommand(0, null, content); try (WinRMClient client = client()) { client.downloadFile(PATH, local); } @@ -414,7 +404,7 @@ void aStreamPathIsNotDownloadedIntoADirectory(@TempDir final Path directory) { void aSizeMismatchFailsEvenWhenTheDigestMatches(@TempDir final Path directory) throws Exception { final byte[] content = { 1, 2, 3 }; enqueueRead(0, null, probe(999, content)); - enqueueCommand(0, null, b64(content) + "\r\n"); + enqueueCommand(0, null, content); final Path local = directory.resolve("copy.bin"); try (WinRMClient client = client()) { final RemoteFile file = client.file(PATH); @@ -431,7 +421,7 @@ void aLongDestinationNameStillFitsTheStagingFile(@TempDir final Path directory) final byte[] content = { 1, 2, 3 }; final Path local = directory.resolve("a".repeat(240) + ".bin"); enqueueRead(0, null, probe(content)); - enqueueCommand(0, null, b64(content) + "\r\n"); + enqueueCommand(0, null, content); try (WinRMClient client = client()) { assertEquals(3, client.downloadFile(PATH, local)); } @@ -461,12 +451,7 @@ void downloadWritesTheVerifiedContent(@TempDir final Path directory) throws Exce content[i] = (byte) i; } enqueueRead(0, null, probe(content)); - enqueueCommand( - 0, - null, - b64(Arrays.copyOfRange(content, 0, 150)) + "\r\n", - b64(Arrays.copyOfRange(content, 150, 300)) + "\r\n" - ); + enqueueCommand(0, null, Arrays.copyOfRange(content, 0, 150), Arrays.copyOfRange(content, 150, 300)); final Path local = directory.resolve("copy.bin"); try (WinRMClient client = client()) { // The range settings do not apply: the whole file is downloaded. @@ -496,7 +481,7 @@ void anIdenticalLocalFileIsNotDownloadedAgain(@TempDir final Path directory) thr void aDirectoryDestinationReceivesTheRemoteFileName(@TempDir final Path directory) throws Exception { final byte[] content = { 1, 2, 3 }; enqueueRead(0, null, probe(content)); - enqueueCommand(0, null, b64(content) + "\r\n"); + enqueueCommand(0, null, content); try (WinRMClient client = client()) { assertEquals(3, client.downloadFile(PATH, directory)); } @@ -507,7 +492,7 @@ void aDirectoryDestinationReceivesTheRemoteFileName(@TempDir final Path director void aDigestMismatchFailsWithoutWritingTheDestination(@TempDir final Path directory) throws Exception { // The file changed between the probe and the read. enqueueRead(0, null, probe("expected".getBytes(StandardCharsets.US_ASCII))); - enqueueCommand(0, null, b64("modified".getBytes(StandardCharsets.US_ASCII)) + "\r\n"); + enqueueCommand(0, null, ascii("modified")); final Path local = directory.resolve("copy.bin"); try (WinRMClient client = client()) { final RemoteFile file = client.file(PATH); @@ -519,7 +504,7 @@ void aDigestMismatchFailsWithoutWritingTheDestination(@TempDir final Path direct @Test void aMalformedProbeIsReported(@TempDir final Path directory) { - enqueueRead(0, null, b64(new byte[] { 1, 2, 3 }) + "\r\n"); + enqueueRead(0, null, new byte[] { 1, 2, 3 }); try (WinRMClient client = client()) { final RemoteFile file = client.file(PATH); final WinRMClientException e = assertThrows(WinRMClientException.class, () -> file.downloadTo(directory)); @@ -548,10 +533,10 @@ void aTimeoutMidTransferLeavesTheDestinationAsItWas(@TempDir final Path director enqueueRead(0, null, probe(content)); server .enqueue(200, envelope(commandResponse(COMMAND_ID))) - .enqueue(200, envelope(receiveResponse(stdout(b64(new byte[600]) + "\r\n"), null))) + .enqueue(200, envelope(receiveResponse(stdout(new byte[600]), null))) .enqueueDelayed( 200, - envelope(receiveResponse(stdout(b64(new byte[400]) + "\r\n"), done(COMMAND_ID, 0))), + envelope(receiveResponse(stdout(new byte[400]), done(COMMAND_ID, 0))), 4_000 ) .enqueue(200, envelope(signalResponse())); diff --git a/src/test/java/org/metricshub/winrm/RemoteFilesScriptTest.java b/src/test/java/org/metricshub/winrm/RemoteFilesScriptTest.java index 78b3cfe..ebac9c2 100644 --- a/src/test/java/org/metricshub/winrm/RemoteFilesScriptTest.java +++ b/src/test/java/org/metricshub/winrm/RemoteFilesScriptTest.java @@ -25,7 +25,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.ByteArrayOutputStream; import java.io.FileOutputStream; import java.io.RandomAccessFile; import java.nio.ByteBuffer; @@ -53,14 +52,14 @@ /** * Runs the PowerShell scripts of {@link RemoteFiles} in the local {@code powershell.exe}, - * exactly as the remote shell would ({@code -EncodedCommand}), and decodes their output the way - * the client does: the actual script semantics (seek, negative offsets, share mode, exit codes) - * are only testable against a real PowerShell. Windows only. + * exactly as the remote shell would ({@code -EncodedCommand}), and reads their output the way the + * client does: the actual script semantics (seek, negative offsets, share mode, exit codes, raw + * output) are only testable against a real PowerShell. Windows only. */ @EnabledOnOs(OS.WINDOWS) class RemoteFilesScriptTest { - /** Every byte value, then enough random bytes to span several transfer blocks. */ + /** Every byte value, then enough random bytes to span several transfer blocks, ending with a CR. */ private static byte[] content; @TempDir @@ -77,11 +76,12 @@ static void createFile() throws Exception { final byte[] random = new byte[content.length - 256]; new Random(42).nextBytes(random); System.arraycopy(random, 0, content, 256, random.length); + content[content.length - 1] = '\r'; file = directory.resolve("données-漢字.bin"); Files.write(file, content); } - /** The outcome of a script: the decoded bytes and the exit code. */ + /** The outcome of a script: the bytes it wrote on stdout and the exit code. */ private static final class Outcome { final byte[] bytes; @@ -94,12 +94,23 @@ private static final class Outcome { } private static Outcome run(final String script) throws Exception { - final Listing raw = exec(script); - final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - for (final String line : raw.lines) { - bytes.write(Base64.getDecoder().decode(line)); + final String encoded = Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE)); + final Process process = new ProcessBuilder( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + encoded + ) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + process.getOutputStream().close(); + final byte[] stdout = process.getInputStream().readAllBytes(); + if (!process.waitFor(60, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new AssertionError("powershell.exe did not complete"); } - return new Outcome(bytes.toByteArray(), raw.exitCode); + return new Outcome(stdout, process.exitValue()); } /** The outcome of a metadata script: its non-blank stdout lines and the exit code. */ @@ -139,28 +150,13 @@ private static String relative(final String path) { } private static Listing exec(final String script) throws Exception { - final String encoded = Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE)); - final Process process = new ProcessBuilder( - "powershell.exe", - "-NoProfile", - "-NonInteractive", - "-EncodedCommand", - encoded - ) - .redirectError(ProcessBuilder.Redirect.DISCARD) - .start(); - process.getOutputStream().close(); - final String stdout = new String(process.getInputStream().readAllBytes(), StandardCharsets.US_ASCII); - if (!process.waitFor(60, TimeUnit.SECONDS)) { - process.destroyForcibly(); - throw new AssertionError("powershell.exe did not complete"); - } + final Outcome outcome = run(script); final List lines = Arrays - .stream(stdout.split("\r?\n")) + .stream(new String(outcome.bytes, StandardCharsets.US_ASCII).split("\r?\n")) .filter(l -> !l.isBlank()) .map(String::strip) .collect(Collectors.toList()); - return new Listing(lines, process.exitValue()); + return new Listing(lines, outcome.exitCode); } private static byte[] read(final long offset, final long length) throws Exception { diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 316c450..15f33c8 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -803,12 +803,9 @@ void catWritesTheRemoteBytesUnconverted() throws Exception { System .arraycopy(new byte[] { '\r', '\n', '\n', '\r', (byte) 0xC3, 0x28, (byte) 0xFF, (byte) 0xFE, 0x1A }, 0, content, 256, 9); - final String lines = b64(Arrays.copyOfRange(content, 0, 200)) + "\r\n" - + b64(Arrays.copyOfRange(content, 200, content.length)) + - "\r\n"; try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { enqueueShellCreation(server); - enqueueScript(server, 0, lines.substring(0, 100), lines.substring(100)); + enqueueScript(server, 0, Arrays.copyOfRange(content, 0, 100), Arrays.copyOfRange(content, 100, content.length)); enqueueShellDeletion(server); final Invocation invocation = invokeAgainst(server, "cat", "C:\\Windows\\Temp\\collect.bin"); @@ -825,7 +822,7 @@ void catWritesTheRemoteBytesUnconverted() throws Exception { void catReadsARangeAndDecodesTextWithTheGivenCharset() throws Exception { try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { enqueueShellCreation(server); - enqueueScript(server, 0, b64(new byte[] { 'c', 'a', 'f', (byte) 0xE9 }) + "\r\n"); + enqueueScript(server, 0, new byte[] { 'c', 'a', 'f', (byte) 0xE9 }); enqueueShellDeletion(server); final Invocation invocation = invokeAgainst( @@ -856,8 +853,8 @@ void aClosedStandardOutputStopsTheRemoteRead() throws Exception { .enqueue(200, FakeWsmanResponses.envelope(FakeWsmanResponses.commandResponse(COMMAND_ID))) .enqueue( 200, - FakeWsmanResponses.envelope(FakeWsmanResponses.receiveResponse(stdoutStream(b64(new byte[] - { 1, 2, 3 }) + "\r\n"), null)) + FakeWsmanResponses.envelope(FakeWsmanResponses.receiveResponse(stdoutStream(new byte[] + { 1, 2, 3 }), null)) ) // ...when the closed output makes the CLI stop it: the terminate Signal, which a real // host answers only when its OperationTimeout expires, the read being blocked writing @@ -893,7 +890,7 @@ void getDownloadsIntoAnExistingDirectoryUnderTheRemoteName(@TempDir final Path d try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { enqueueShellCreation(server); enqueueScript(server, 0, probe(content)); - enqueueScript(server, 0, b64(content) + "\r\n"); + enqueueScript(server, 0, content); enqueueShellDeletion(server); final Invocation invocation = invokeAgainst( @@ -962,27 +959,33 @@ private static String json(final String path, final String mode, final int attri } /** The probe output of a download: the size (8 bytes, little-endian), then the SHA-256 digest. */ - private static String probe(final byte[] content) throws Exception { - return b64( - ByteBuffer - .allocate(40) - .order(ByteOrder.LITTLE_ENDIAN) - .putLong(content.length) - .put(MessageDigest.getInstance("SHA-256").digest(content)) - .array() - ) + - "\r\n"; + private static byte[] probe(final byte[] content) throws Exception { + return ByteBuffer + .allocate(40) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(content.length) + .put(MessageDigest.getInstance("SHA-256").digest(content)) + .array(); } - private static String stdoutStream(final String text) { - return FakeWsmanResponses.stream("stdout", COMMAND_ID, text.getBytes(StandardCharsets.US_ASCII)); + private static String stdoutStream(final byte[] bytes) { + return FakeWsmanResponses.stream("stdout", COMMAND_ID, bytes); + } + + /** {@link #enqueueScript(FakeWsmanServer, int, byte[]...)} for a script writing text: the metadata records. */ + private static void enqueueScript(final FakeWsmanServer server, final int exitCode, final String... chunks) { + enqueueScript( + server, + exitCode, + Arrays.stream(chunks).map(chunk -> chunk.getBytes(StandardCharsets.US_ASCII)).toArray(byte[][]::new) + ); } /** * Script one remote file script on the existing shell: the command, one Receive per stdout * chunk (the last one completes with the exit code), and the Signal ending it. */ - private static void enqueueScript(final FakeWsmanServer server, final int exitCode, final String... chunks) { + private static void enqueueScript(final FakeWsmanServer server, final int exitCode, final byte[]... chunks) { server.enqueue(200, FakeWsmanResponses.envelope(FakeWsmanResponses.commandResponse(COMMAND_ID))); for (int i = 0; i < chunks.length; i++) { final boolean last = i == chunks.length - 1;