From 927193181a3e7efee94cccb5d01efe55e641a5d8 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Sat, 26 Sep 2026 01:26:48 +0200 Subject: [PATCH 1/3] Bound the terminate Signal of a command closed before it completed Closing a command early sends the terminate Signal. The service kills the process at once, but when the process is blocked writing a block larger than its output pipe, it answers the Signal only when the Signal's OperationTimeout expires (WSManFault 2150858793, measured on Windows Server 2022). Every remote file read writes 64 KB lines, so closing openStream(), openReader() or a listing's stream() early took the whole timeout, then threw from close(). The early-close Signal now asks for a 1 s hold, and the expiry of that hold is not a failure: it is a complete exchange, the connection stays in sync, and the process is gone. Early close of a remote read: 30 s and an exception before, 1 s after. Found with the CLI's "cat ... | head" (#148). Co-Authored-By: Claude Opus 5.5 --- .../metricshub/winrm/light/WsmanClient.java | 24 +++++++++++++- .../metricshub/winrm/StreamingApiTest.java | 31 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 791d14d..0913604 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -69,6 +69,12 @@ final class WsmanClient implements AutoCloseable { // wire. private static final long MIN_WIRE_POLL_MS = 750; + // How long the service may hold the terminate Signal of a command closed before it completed. + // The command is killed at once, but when it is blocked writing a block larger than its output + // pipe (a remote file read writes 64 KB lines), the service answers only when the Signal's + // OperationTimeout expires — the whole inactivity timeout, measured on Windows Server 2022. + private static final long EARLY_CLOSE_SIGNAL_MS = 1_000; + // WS-Enumeration namespace: the EndOfSequence / EnumerationContext markers live here. Match them by // namespace, never by local name alone, so a WMI property that happens to be named "EndOfSequence" // or "EnumerationContext" inside cannot be mistaken for the enumeration control element. @@ -867,7 +873,7 @@ private void finish() throws Exception { if (exitCode != null) { terminateCompleted(operationTimeoutMs); } else { - terminate(commandId, operationTimeoutMs); + terminateRunning(); } } } finally { @@ -875,6 +881,22 @@ private void finish() throws Exception { } } + /** + * Terminate a command closed before it completed: the Signal is what stops it, so its + * failures are reported — except the expiry of its short hold (see + * {@link #EARLY_CLOSE_SIGNAL_MS}), a complete exchange that leaves the connection in sync + * and the command killed. + */ + private void terminateRunning() throws Exception { + try { + terminate(commandId, Math.min(EARLY_CLOSE_SIGNAL_MS, operationTimeoutMs)); + } catch (final WinRMFaultException e) { + if (!FAULT_OPERATION_TIMEOUT.equals(e.getFaultCode())) { + throw e; + } + } + } + /** * Completion cleanup under a poll budget: like {@link #finish()} after completion, but the * Signal must not outlive the caller's remaining wait either. Runs at most once. diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java index 014deca..a3e366b 100644 --- a/src/test/java/org/metricshub/winrm/StreamingApiTest.java +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -399,6 +399,37 @@ void closingTheProcessEarlyTerminatesTheRemoteCommand() throws Exception { } } + @Test + void anEarlyTerminateAnsweredByTheExpiryOfItsShortHoldIsNotAFailure() throws Exception { + // A command blocked writing a block larger than its output pipe (a remote file read) is + // killed by the terminate Signal, but a real service answers only when the Signal's + // OperationTimeout expires: the Signal asks for a short hold, whose expiry is no failure. + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("block\n"), null))) + .enqueue( + 500, + fault( + "2150858793", + "The WS-Management service cannot complete the operation within the time specified in OperationTimeout." + ) + ); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("type huge.bin").charset(StandardCharsets.UTF_8).start(); + assertEquals("block", process.stdout().readLine()); + process.close(); + + final String signal = server.decryptedRequests().get(3); + assertTrue(signal.contains("signal/terminate"), signal); + assertTrue(signal.contains("PT1S"), signal); + + // The fault was a complete exchange: the connection is still in sync. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + @Test void closingAfterTheFinalChunkStillExposesTheExitCode() throws Exception { enqueueCommandStartup(); From 3d02741cd1e26f83652cebab327a3783ed5ccfa9 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Sat, 26 Sep 2026 01:26:48 +0200 Subject: [PATCH 2/3] CLI: ls, stat, cat and get subcommands for remote files (#148) Thin mappings onto the remote file access of the library: - ls : file(dir).list()...stream(), one fixed line per entry (mode, size, ISO-8601 UTC time with 100 ns precision, path), written as the host walks the tree. --glob, --recursive, --depth, --files-only, --directories-only, --modified-after, --min-size, --json. - stat : file(path).info(), one field per line, or --json. - cat : file(path).openStream() copied to stdout byte for byte; --offset (negative: from the end), --length, --charset. - get []: file(path).downloadTo(...), into the current directory under the remote name by default. Options follow the subcommand; remote paths are passed through untouched. JsonLinesWriter now writes numbers as JSON numbers (WQL rows carry strings only, so the wql output is unchanged). New exit codes: 1 when ls could not read some directories (each reported on stderr), 66 for a remote path not found, 74 for a local I/O failure (stdout closed, or the local file of get, previously reported as a connection failure). A closed stdout ("| head") stops the remote transfer. cli.md documents the subcommands, their options, output formats, exit codes and the local shell quoting of Windows paths; it also fixes four anchors broken under Doxia 2. --help lists the new options, README gets one example. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 + .../metricshub/winrm/cli/CliArguments.java | 266 +++++++++- .../metricshub/winrm/cli/JsonLinesWriter.java | 2 + .../org/metricshub/winrm/cli/WinRmCli.java | 255 +++++++++- src/site/markdown/cli.md | 202 +++++++- src/site/markdown/files.md | 3 +- .../winrm/cli/CliArgumentsTest.java | 122 ++++- .../winrm/cli/JsonLinesWriterTest.java | 5 +- .../metricshub/winrm/cli/WinRmCliTest.java | 474 +++++++++++++++++- 9 files changed, 1291 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index a40ab5d..5e2faab 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,14 @@ java -jar target/winrm-java--standalone.jar \ -h server.example.net -u 'DOMAIN\user' -pf password.txt shell ``` +Print the last 8 KiB of a remote log (`ls`, `stat`, and `get` list, describe, and download +remote files): + +```bash +java -jar target/winrm-java--standalone.jar \ + -h server.example.net -u 'DOMAIN\user' -pf password.txt cat 'D:\logs\app.log' --offset -8192 +``` + Use `--help` for the option list and `--version` for the build version. The CLI is built on the streaming API: WQL rows are written to stdout as UTF-8 [JSON Lines](https://jsonlines.org/) **as the enumeration pages arrive**, and remote command stdout and stderr are forwarded **live** to the diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 409ebc5..5099c3c 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -24,17 +24,24 @@ import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.service.WinRMEndpoint; import org.metricshub.winrm.service.client.auth.AuthenticationEnum; @@ -46,7 +53,11 @@ enum Operation { VERSION, WQL, COMMAND, - SHELL + SHELL, + LS, + STAT, + CAT, + GET } static final long DEFAULT_TIMEOUT = 60_000L; @@ -74,6 +85,18 @@ enum Operation { private final String directory; private final Map environment; private final String input; + private final Path localFile; + private final String glob; + private final boolean recursive; + private final int depth; + private final boolean filesOnly; + private final boolean directoriesOnly; + private final Instant modifiedAfter; + private final long minSize; + private final boolean json; + private final long offset; + private final long length; + private final Charset charset; private CliArguments(final Builder builder) { operation = builder.operation; @@ -94,6 +117,18 @@ private CliArguments(final Builder builder) { directory = builder.directory; environment = builder.environment; input = builder.input; + localFile = builder.localFile; + glob = builder.glob; + recursive = builder.recursive; + depth = builder.depth; + filesOnly = builder.filesOnly; + directoriesOnly = builder.directoriesOnly; + modifiedAfter = builder.modifiedAfter; + minSize = builder.minSize; + json = builder.json; + offset = builder.offset; + length = builder.length; + charset = builder.charset; } static CliArguments parse(final String[] arguments) throws CliUsageException { @@ -215,6 +250,11 @@ private static void parseEnvironmentVariable(final Builder builder, final String private static void parseOperation(final Builder builder, final String name, final List values) throws CliUsageException { + if (isFileSubcommand(name)) { + builder.operation = Operation.valueOf(name.toUpperCase(Locale.ROOT)); + parseFileArguments(builder, name, values.toArray(new String[0])); + return; + } if ("shell".equals(name)) { builder.operation = Operation.SHELL; if (!values.isEmpty()) { @@ -234,12 +274,139 @@ private static void parseOperation(final Builder builder, final String name, fin } } + /** + * Parse what follows a file subcommand: its remote path (and, for {@code get}, an optional + * local path), passed through untouched, and its own options, in any order. Anything starting + * with {@code -} is an option. + */ + private static void parseFileArguments(final Builder builder, final String name, final String[] arguments) + throws CliUsageException { + final List paths = new ArrayList<>(2); + int index = 0; + while (index < arguments.length) { + if (arguments[index].startsWith("-")) { + index = parseFileOption(builder, arguments, index); + } else { + paths.add(arguments[index]); + index++; + } + } + final boolean get = builder.operation == Operation.GET; + if (paths.isEmpty() || paths.size() > (get ? 2 : 1) || isBlank(paths.get(0))) { + throw new CliUsageException( + get ? "get requires a remote path and an optional local path" : name + " requires one remote path" + ); + } + builder.input = paths.get(0); + if (paths.size() == 2) { + try { + builder.localFile = Path.of(paths.get(1)); + } catch (final InvalidPathException e) { + throw new CliUsageException("get: invalid local path", e); + } + } + } + + private static int parseFileOption(final Builder builder, final String[] arguments, final int index) + throws CliUsageException { + final String argument = arguments[index]; + final String option = optionName(argument); + switch (option) { + case "--glob": + requireSubcommand(builder, option, Operation.LS); + builder.glob = optionValue(arguments, index, option); + return nextIndex(argument, index); + case "--recursive": + requireSubcommand(builder, option, Operation.LS); + builder.recursive = true; + return index + 1; + case "--depth": + requireSubcommand(builder, option, Operation.LS); + builder.depth = (int) Math + .min(Integer.MAX_VALUE, parsePositiveNumber(optionValue(arguments, index, option), option)); + return nextIndex(argument, index); + case "--files-only": + requireSubcommand(builder, option, Operation.LS); + builder.filesOnly = true; + return index + 1; + case "--directories-only": + requireSubcommand(builder, option, Operation.LS); + builder.directoriesOnly = true; + return index + 1; + case "--modified-after": + requireSubcommand(builder, option, Operation.LS); + builder.modifiedAfter = parseInstant(optionValue(arguments, index, option), option); + return nextIndex(argument, index); + case "--min-size": + requireSubcommand(builder, option, Operation.LS); + builder.minSize = parsePositiveNumber(optionValue(arguments, index, option), option); + return nextIndex(argument, index); + case "--json": + requireSubcommand(builder, option, Operation.LS, Operation.STAT); + builder.json = true; + return index + 1; + case "--offset": + requireSubcommand(builder, option, Operation.CAT); + builder.offset = parseNumber(optionValue(arguments, index, option), option); + return nextIndex(argument, index); + case "--length": + requireSubcommand(builder, option, Operation.CAT); + builder.length = parsePositiveNumber(optionValue(arguments, index, option), option); + return nextIndex(argument, index); + case "--charset": + requireSubcommand(builder, option, Operation.CAT); + builder.charset = parseCharset(optionValue(arguments, index, option), option); + return nextIndex(argument, index); + default: + throw new CliUsageException("unknown option " + safeOptionName(argument)); + } + } + + private static void requireSubcommand(final Builder builder, final String option, final Operation... operations) + throws CliUsageException { + if (!Arrays.asList(operations).contains(builder.operation)) { + throw new CliUsageException( + option + " requires the " + + Arrays.stream(operations).map(o -> o.name().toLowerCase(Locale.ROOT)).collect(Collectors.joining(" or ")) + + " subcommand" + ); + } + } + + /** + * An ISO-8601 date-time with an offset ({@code 2026-01-31T12:00:00Z}), or a date + * ({@code 2026-01-31}), which stands for midnight UTC. A date-time without an offset is refused: + * its time zone would be a guess. + */ + private static Instant parseInstant(final String value, final String option) throws CliUsageException { + try { + return OffsetDateTime.parse(value).toInstant(); + } catch (final DateTimeParseException e) { + try { + return LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant(); + } catch (final DateTimeParseException notADate) { + throw new CliUsageException( + option + " must be an ISO-8601 date or date-time with an offset, e.g. 2026-01-31 or 2026-01-31T12:00:00Z", + e + ); + } + } + } + + private static Charset parseCharset(final String value, final String option) throws CliUsageException { + try { + return Charset.forName(value); + } catch (final IllegalArgumentException e) { + throw new CliUsageException(option + " must name a charset known to Java, e.g. UTF-8 or windows-1252", e); + } + } + private static void validate(final Builder builder) throws CliUsageException { if (builder.operation == Operation.HELP || builder.operation == Operation.VERSION) { return; } if (builder.operation == null) { - throw new CliUsageException("missing subcommand (wql, command, or shell)"); + throw new CliUsageException("missing subcommand (wql, command, shell, ls, stat, cat, or get)"); } if (builder.operation != Operation.SHELL && isBlank(builder.input)) { throw new CliUsageException( @@ -285,12 +452,16 @@ private static void validate(final Builder builder) throws CliUsageException { if (builder.directory != null && builder.directory.trim().isEmpty()) { throw new CliUsageException("--directory requires a value"); } - if (builder.directory != null && builder.operation == Operation.WQL) { + final boolean runsInShell = builder.operation == Operation.COMMAND || builder.operation == Operation.SHELL; + if (builder.directory != null && !runsInShell) { throw new CliUsageException("--directory requires the command or shell subcommand"); } - if (!builder.environment.isEmpty() && builder.operation == Operation.WQL) { + if (!builder.environment.isEmpty() && !runsInShell) { throw new CliUsageException("--env requires the command or shell subcommand"); } + if (builder.filesOnly && builder.directoriesOnly) { + throw new CliUsageException("--files-only and --directories-only are mutually exclusive"); + } if (builder.operation == Operation.SHELL && builder.timeout < MIN_SHELL_TIMEOUT) { throw new CliUsageException("shell requires --timeout of at least " + MIN_SHELL_TIMEOUT + " milliseconds"); } @@ -421,12 +592,16 @@ private static long parseTimeout(final String value, final String option) throws } private static long parsePositiveNumber(final String value, final String option) throws CliUsageException { + final long number = parseNumber(value, option); + if (number <= 0) { + throw new CliUsageException(option + " must be greater than zero"); + } + return number; + } + + private static long parseNumber(final String value, final String option) throws CliUsageException { try { - final long number = Long.parseLong(value); - if (number <= 0) { - throw new CliUsageException(option + " must be greater than zero"); - } - return number; + return Long.parseLong(value); } catch (final NumberFormatException e) { throw new CliUsageException(option + " must be a number", e); } @@ -459,7 +634,13 @@ private static boolean isSubcommand(final String value) { || "run".equals(value) || - "shell".equals(value); + "shell".equals(value) + || + isFileSubcommand(value); + } + + private static boolean isFileSubcommand(final String value) { + return "ls".equals(value) || "stat".equals(value) || "cat".equals(value) || "get".equals(value); } private static boolean isBlank(final String value) { @@ -535,10 +716,63 @@ Map environment() { return environment; } + /** The query, the command line, or the remote path of a file subcommand. */ String input() { return input; } + /** The local destination of {@code get}, or {@code null} for the current directory. */ + Path localFile() { + return localFile; + } + + String glob() { + return glob; + } + + boolean recursive() { + return recursive; + } + + /** The deepest level {@code ls} lists, or 0 when not set. */ + int depth() { + return depth; + } + + boolean filesOnly() { + return filesOnly; + } + + boolean directoriesOnly() { + return directoriesOnly; + } + + Instant modifiedAfter() { + return modifiedAfter; + } + + long minSize() { + return minSize; + } + + boolean json() { + return json; + } + + long offset() { + return offset; + } + + /** How many bytes {@code cat} reads at most, or -1 to read to the end. */ + long length() { + return length; + } + + /** The charset {@code cat} decodes the file with, or {@code null} to copy the bytes. */ + Charset charset() { + return charset; + } + @Override public void close() { if (password != null) { @@ -568,6 +802,18 @@ private static final class Builder { private Integer port; private long timeout = DEFAULT_TIMEOUT; private String input; + private Path localFile; + private String glob; + private boolean recursive; + private int depth; + private boolean filesOnly; + private boolean directoriesOnly; + private Instant modifiedAfter; + private long minSize; + private boolean json; + private long offset; + private long length = -1; + private Charset charset; private void replacePassword(final char[] replacement) { clearPassword(); diff --git a/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java b/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java index 661f550..fedeadd 100644 --- a/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java +++ b/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java @@ -41,6 +41,8 @@ static void write(final Map row, final PrintStream output) { final Object value = entry.getValue(); if (value == null) { json.append("null"); + } else if (value instanceof Number) { + json.append(value); } else { writeString(String.valueOf(value), json); } diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index 2d4e0b1..cb8f3c4 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -25,7 +25,9 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.PrintStream; +import java.io.Reader; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; @@ -34,10 +36,18 @@ import java.net.SocketException; import java.net.UnknownHostException; import java.nio.charset.Charset; +import java.nio.file.FileSystemException; +import java.nio.file.Path; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; @@ -45,6 +55,9 @@ import javax.net.ssl.SSLException; import org.metricshub.winrm.AuthScheme; import org.metricshub.winrm.CommandRequest; +import org.metricshub.winrm.RemoteDirectoryListing; +import org.metricshub.winrm.RemoteFile; +import org.metricshub.winrm.RemoteFileInfo; import org.metricshub.winrm.RemoteProcess; import org.metricshub.winrm.WinRMClient; import org.metricshub.winrm.WinRMHttpProtocolEnum; @@ -63,7 +76,9 @@ * stream while the command runs; when the local standard input is not an interactive console * (piped or redirected), it is forwarded as the remote command's standard input. The {@code shell} * subcommand starts {@code cmd.exe} on the remote host and bridges it to the local terminal, - * line by line, until the remote shell exits. Diagnostics are written only to standard error. + * line by line, until the remote shell exits. The {@code ls}, {@code stat}, {@code cat} and + * {@code get} subcommands list, describe, print and download remote files; {@code cat} copies the + * file's bytes to standard output unconverted. Diagnostics are written only to standard error. *

* NTLM is the default authentication scheme. Kerberos requires HTTPS. HTTPS validates certificates * and hostnames unless the explicitly insecure {@code --https-permissive} option is used. @@ -71,21 +86,41 @@ * arguments can be visible to other local processes and should be avoided in automation. When * neither password option is supplied, the password is read securely from the interactive console. *

- * Usage errors exit with 64, connection/TLS errors with 69, WinRM protocol errors with 70, - * authentication errors with 77, and timeouts with 124. Representable remote command exit codes - * (0 through 255) are propagated directly. + * Usage errors exit with 64, a remote path not found with 66, connection/TLS errors with 69, WinRM + * protocol and other remote errors with 70, local I/O errors with 74, authentication errors with + * 77, and timeouts with 124; an {@code ls} that could not read some directories exits with 1. + * Representable remote command exit codes (0 through 255) are propagated directly. */ public final class WinRmCli { + static final int EXIT_PARTIAL = 1; static final int EXIT_USAGE = 64; + static final int EXIT_NOT_FOUND = 66; static final int EXIT_PROTOCOL = 70; static final int EXIT_CONNECTION = 69; + static final int EXIT_IO = 74; static final int EXIT_AUTHENTICATION = 77; static final int EXIT_TIMEOUT = 124; private static final String KERBEROS_KDC_PROPERTY = "java.security.krb5.kdc"; private static final String KERBEROS_REALM_PROPERTY = "java.security.krb5.realm"; + /** + * How the library's remote file access reports a missing path (see + * {@code RemoteFiles.failure}): the message is all that tells it apart. + */ + private static final String REMOTE_PATH_NOT_FOUND = "Remote path not found"; + + /** + * The timestamps of the file subcommands: ISO-8601 UTC with the 100 ns precision of Windows + * file times, always 28 characters, so they align in a listing and sort as strings. + */ + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter + .ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSSSSS'Z'", Locale.ROOT) + .withZone(ZoneOffset.UTC); + + private static final int COPY_BUFFER_SIZE = 65_536; + private WinRmCli() {} /** @@ -250,6 +285,9 @@ private static int execute( if (arguments.operation() == CliArguments.Operation.SHELL) { return interactiveShell(arguments, standardOutput, standardError, remote, localInput); } + if (arguments.operation() != CliArguments.Operation.COMMAND) { + return fileOperation(arguments, remote.file(arguments.input()), standardOutput, standardError); + } // Forward each output chunk as it arrives, so a long-running command can be followed live. // Piped/redirected local standard input travels the other way, as the command's stdin — // automatically when detected, unconditionally with --stdin. @@ -313,6 +351,183 @@ private static int interactiveShell( } } + /** Run {@code ls}, {@code stat}, {@code cat} or {@code get} on the given remote path. */ + private static int fileOperation( + final CliArguments arguments, + final RemoteFile file, + final PrintStream standardOutput, + final PrintStream standardError + ) throws IOException { + switch (arguments.operation()) { + case LS: + return list(arguments, file.list(), standardOutput, standardError); + case STAT: + final Optional info = file.info(); + if (info.isEmpty()) { + diagnostic(standardError, REMOTE_PATH_NOT_FOUND + " on " + arguments.hostname() + ": " + file.path()); + return EXIT_NOT_FOUND; + } + if (arguments.json()) { + JsonLinesWriter.write(fields(info.get()), standardOutput); + } else { + fields(info.get()).forEach((name, value) -> standardOutput.println(name + ": " + value)); + } + return standardOutput.checkError() ? outputFailed(standardError) : 0; + case CAT: + file.offset(arguments.offset()); + if (arguments.length() >= 0) { + file.length(arguments.length()); + } + return cat(file, arguments.charset(), standardOutput, standardError); + default: + // get: into the current directory, under the remote name, unless told otherwise + file.downloadTo(arguments.localFile() == null ? Path.of("") : arguments.localFile()); + return 0; + } + } + + /** + * Write each entry as the host reports it, and each directory that could not be read to + * standard error; such a directory makes the listing partial, hence a nonzero exit code. + */ + private static int list( + final CliArguments arguments, + final RemoteDirectoryListing listing, + final PrintStream standardOutput, + final PrintStream standardError + ) { + if (arguments.glob() != null) { + listing.glob(arguments.glob()); + } + if (arguments.depth() > 0) { + listing.maxDepth(arguments.depth()); + } else if (arguments.recursive()) { + listing.recursive(); + } + if (arguments.filesOnly()) { + listing.filesOnly(); + } + if (arguments.directoriesOnly()) { + listing.directoriesOnly(); + } + if (arguments.modifiedAfter() != null) { + listing.modifiedAfter(arguments.modifiedAfter()); + } + listing.minSize(arguments.minSize()); + final AtomicBoolean partial = new AtomicBoolean(); + listing.onInaccessible(path -> { + partial.set(true); + diagnostic(standardError, "cannot read directory " + path); + }); + try (Stream entries = listing.stream()) { + for (final Iterator iterator = entries.iterator(); iterator.hasNext();) { + final RemoteFileInfo entry = iterator.next(); + if (arguments.json()) { + JsonLinesWriter.write(fields(entry), standardOutput); + } else { + standardOutput.println(line(entry)); + } + // Also flushes, so a downstream pipe sees each entry as it arrives. A closed output + // (e.g. "| head") stops the remote walk instead of finishing it for nobody. + if (standardOutput.checkError()) { + return outputFailed(standardError); + } + } + } + return partial.get() ? EXIT_PARTIAL : 0; + } + + /** + * Copy the file to standard output as it arrives: its bytes unconverted, or, with a charset, + * its text decoded with that charset and encoded for the local console. + */ + private static int cat( + final RemoteFile file, + final Charset charset, + final PrintStream standardOutput, + final PrintStream standardError + ) throws IOException { + try (InputStream in = file.openStream()) { + if (charset == null) { + final byte[] buffer = new byte[COPY_BUFFER_SIZE]; + for (int n = in.read(buffer); n != -1; n = in.read(buffer)) { + standardOutput.write(buffer, 0, n); + // Closing the stream early stops the remote read (e.g. "| head") + if (standardOutput.checkError()) { + return outputFailed(standardError); + } + } + return 0; + } + try (Reader reader = new InputStreamReader(in, charset)) { + final char[] buffer = new char[COPY_BUFFER_SIZE]; + for (int n = reader.read(buffer); n != -1; n = reader.read(buffer)) { + standardOutput.print(new String(buffer, 0, n)); + if (standardOutput.checkError()) { + return outputFailed(standardError); + } + } + return 0; + } + } + } + + private static int outputFailed(final PrintStream standardError) { + diagnostic(standardError, "cannot write to standard output"); + return EXIT_IO; + } + + /** + * The fields of an entry, as {@code stat} and {@code --json} report them: the path, the + * attributes as a PowerShell-style mode string ({@code darhsl}) and as the raw Windows + * {@code FileAttributes} value, the size, and the timestamps. + */ + private static Map fields(final RemoteFileInfo info) { + final Map fields = new LinkedHashMap<>(); + fields.put("path", info.path()); + fields.put("mode", mode(info)); + fields.put("attributes", info.attributes()); + fields.put("size", info.size()); + fields.put("lastModified", timestamp(info.lastModified())); + fields.put("created", timestamp(info.created())); + fields.put("lastAccessed", timestamp(info.lastAccessed())); + return fields; + } + + /** The {@code ls} line of an entry: mode, size, last modification time, path. */ + private static String line(final RemoteFileInfo info) { + return String.format( + Locale.ROOT, + "%s %12d %s %s", + mode(info), + info.size(), + timestamp(info.lastModified()), + info.path() + ); + } + + /** + * The attributes the way Windows PowerShell's {@code Mode} column shows them: directory, + * archive, read-only, hidden, system, reparse point ({@code l}), or {@code -}. + */ + private static String mode(final RemoteFileInfo info) { + return new String( + new char[] + { + info.isDirectory() ? 'd' : '-', + info.isArchive() ? 'a' : '-', + info.isReadOnly() ? 'r' : '-', + info.isHidden() ? 'h' : '-', + info.isSystem() ? 's' : '-', + info.isReparsePoint() ? 'l' : '-' + } + ); + } + + private static String timestamp(final Instant instant) { + return TIMESTAMP.format(instant); + } + /** * Reroute Ctrl+C (SIGINT) into the given flag instead of killing this JVM, so the interactive * shell can forward it to the remote child process. Uses {@code sun.misc.Signal} reflectively: @@ -420,6 +635,13 @@ private static int classify(final Throwable throwable) { for (Throwable current = throwable; current != null; current = current.getCause()) { final String className = current.getClass().getName(); final String message = current.getMessage(); + if (message != null && message.startsWith(REMOTE_PATH_NOT_FOUND)) { + return EXIT_NOT_FOUND; + } + // A local file (the destination of get): not a connection problem + if (current instanceof FileSystemException) { + return EXIT_IO; + } if (className.startsWith("javax.security.auth.login.") || className.startsWith("org.ietf.jgss.") @@ -495,6 +717,10 @@ private static String help() { " winrm-java [options] wql \n" + " winrm-java [options] command|cmd|exec|run \n" + " winrm-java [options] shell\n" + + " winrm-java [options] ls [ls options]\n" + + " winrm-java [options] stat [--json]\n" + + " winrm-java [options] cat [cat options]\n" + + " winrm-java [options] get []\n" + "\n" + "Connection options:\n" + " -h, --hostname Target hostname or IP address (required)\n" + @@ -516,6 +742,19 @@ private static String help() { " --help Show this help\n" + " --version Show the project version\n" + "\n" + + "File options (after the subcommand):\n" + + " --glob ls: only names matching a wildcard pattern (* and ?)\n" + + " --recursive ls: list the whole tree\n" + + " --depth ls: list the tree down to depth n (1: the directory's own entries)\n" + + " --files-only ls: list files only\n" + + " --directories-only ls: list directories only\n" + + " --modified-after ls: only entries modified after an ISO-8601 date or date-time\n" + + " --min-size ls: only files of at least this size\n" + + " --json ls, stat: print JSON Lines instead of text\n" + + " --offset cat: start at this byte (negative: from the end)\n" + + " --length cat: read at most this many bytes\n" + + " --charset cat: print the text decoded with this charset, not the raw bytes\n" + + "\n" + "If neither password option is given, the password is requested from the interactive console.\n" + "\n" + "Full manual - streaming behavior, password files, Kerberos, exit codes:\n" + @@ -580,6 +819,9 @@ int shell( AtomicBoolean interruptRequested ) throws Exception; + /** Designate a file or directory on the remote host, with the connection's timeout. */ + RemoteFile file(String path); + @Override void close(); } @@ -791,6 +1033,11 @@ private int remoteAnsiCodePage(final long timeout) { return DEFAULT_SHELL_CODE_PAGE; } + @Override + public RemoteFile file(final String path) { + return client.file(path); + } + @Override public void close() { client.close(); diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 581545c..8f85e35 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -1,5 +1,5 @@ -keywords: cli, command line, standalone, jar, wql, exec, shell, interactive, stdin, exit codes, manual -description: Manual page of the winrm-java standalone command-line client - subcommands, options, passwords, authentication schemes (NTLM, Kerberos, Basic), streaming output, the interactive shell, and exit codes. +keywords: cli, command line, standalone, jar, wql, exec, shell, interactive, stdin, ls, stat, cat, get, remote files, exit codes, manual +description: Manual page of the winrm-java standalone command-line client - subcommands, options, passwords, authentication schemes (NTLM, Kerberos, Basic), streaming output, the interactive shell, remote files (ls, stat, cat, get), and exit codes. # Command-Line Client @@ -16,6 +16,10 @@ This page is its manual. java -jar winrm-java-standalone.jar [options] wql java -jar winrm-java-standalone.jar [options] command|cmd|exec|run java -jar winrm-java-standalone.jar [options] shell +java -jar winrm-java-standalone.jar [options] ls [ls options] +java -jar winrm-java-standalone.jar [options] stat [--json] +java -jar winrm-java-standalone.jar [options] cat [cat options] +java -jar winrm-java-standalone.jar [options] get [] java -jar winrm-java-standalone.jar --help | --version ``` @@ -25,11 +29,16 @@ java -jar winrm-java-standalone.jar --help | --version | --- | --- | | `wql ` | Run a WQL query and print the rows to stdout as UTF-8 [JSON Lines](https://jsonlines.org/). | | `command ` | Run a command on the remote host, forwarding its output. `cmd`, `exec`, and `run` are aliases. | -| `shell` | Open an interactive `cmd.exe` session on the remote host (see [Interactive shell](#Interactive_shell)). | +| `shell` | Open an interactive `cmd.exe` session on the remote host (see [Interactive shell](#interactive-shell)). | +| `ls ` | List a remote directory, or a tree, one entry per line (see [Remote files](#remote-files)). | +| `stat ` | Print the properties of a remote file or directory. | +| `cat ` | Copy the bytes of a remote file, or of a byte range, to stdout. | +| `get []` | Download a remote file to a local file, digest-verified. | -Everything after the subcommand is the query or the command line; quoting follows your local -shell's rules, and multi-word command lines are reassembled for the remote `cmd.exe`. `shell` -takes no argument. +For `wql` and `command`, everything after the subcommand is the query or the command line; +quoting follows your local shell's rules, and multi-word command lines are reassembled for the +remote `cmd.exe`. `shell` takes no argument. The file subcommands take a remote path, then their +own options, in any order. ## Options @@ -40,7 +49,7 @@ takes no argument. | `-p, --password ` | Password. Command-line arguments may be visible to other local processes: avoid in automation. | | `-pf, --password-file ` | Read the password from a UTF-8 file (preferred for automation, see below). | | `-P, --port ` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. | -| `-t, --timeout ` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#Timeout_semantics). | +| `-t, --timeout ` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#timeout-semantics). | | `-d, --directory ` | Working directory the remote command or interactive shell starts in, like `winrs -d` (only with `command` and `shell`). Default: the remote user's profile directory. | | `--env ` | Environment variable set in the remote shell, like `winrs -env` (only with `command` and `shell`). Repeatable — one occurrence per variable; the value is split on the first `=`, so it may itself contain `=`. | | `-i, --stdin` | Forward the local standard input to the remote command (only with `command`); see below. | @@ -56,6 +65,24 @@ takes no argument. `--ntlm`, `--kerberos`, and `--basic` are mutually exclusive, as are the two password options. +### File options + +The options of `ls`, `stat` and `cat` come **after** the subcommand, before or after the path: + +| Option | Description | +| --- | --- | +| `--glob ` | `ls`: only the entries whose name matches a Windows wildcard pattern: `*` any sequence, `?` one character, case-insensitive, whole name (`*.log` matches `app.log`, not `app.log.1`). | +| `--recursive` | `ls`: list the whole tree below the directory, depth-first. | +| `--depth ` | `ls`: list the tree down to depth `n` (1 is the directory's own entries); implies `--recursive`. | +| `--files-only` | `ls`: files only. | +| `--directories-only` | `ls`: directories only. Mutually exclusive with `--files-only`. | +| `--modified-after ` | `ls`: only the entries last modified after an ISO-8601 date (`2026-01-31`, midnight UTC) or date-time with an offset (`2026-01-31T12:00:00Z`, `2026-01-31T14:00:00+02:00`). | +| `--min-size ` | `ls`: only the files of at least this size (directories are not filtered by size). | +| `--json` | `ls`, `stat`: print UTF-8 [JSON Lines](https://jsonlines.org/) instead of text. | +| `--offset ` | `cat`: start at this byte; a negative offset counts from the end of the file. | +| `--length ` | `cat`: read at most this many bytes. | +| `--charset ` | `cat`: decode the file with this charset (`UTF-8`, `UTF-16LE`, `windows-1252`...) and print the text, instead of copying the bytes. | + ## Passwords If neither `-p` nor `-pf` is supplied, the CLI securely requests the password from the interactive @@ -124,6 +151,10 @@ undetectable case is a pipe whose producer has written nothing by the time the C read: piping a large input into a command that floods its output at the same time can deadlock both sides (the classic pipe deadlock), exactly as with `java.lang.Process`. +### `ls`, `stat`, `cat`, `get` + +See [Remote files](#remote-files). + ## Interactive shell ```bash @@ -133,7 +164,7 @@ java -jar winrm-java-standalone.jar -h server.example.net -u 'DOMAIN\user' -pf p `shell` starts `cmd.exe` on the remote host and bridges it to the local terminal until the remote shell exits (type `exit`, or send end-of-input — Ctrl+Z then Enter on Windows, Ctrl+D elsewhere — which the session turns into an `exit`). The remote exit code is propagated through the usual -[exit-code contract](#Exit_codes). +[exit-code contract](#exit-codes). * **Echo is off** — the remote shell runs `cmd.exe /Q`, so the input you forward is never repeated back: your terminal already shows what you type, and the output stream carries the @@ -163,17 +194,151 @@ which the session turns into an `exit`). The remote exit code is propagated thro rejected for `shell`: one poll round trip cannot complete faster (the WSMan service holds a bounded request for at least 500 ms before answering "nothing yet"). +## Remote files + +`ls`, `stat`, `cat`, and `get` reach the remote file system through the WinRM connection itself — +no SMB, no share, no extra port — with the library's [remote file access](files.html): a small +PowerShell script does the work on the host, which needs PowerShell 2.0 or later in +`FullLanguage` mode. Nothing is written on the host. + +```bash +# List: long format, machine-readable timestamps and sizes +java -jar winrm-java-standalone.jar -h server -u 'DOMAIN\user' -pf pw.txt \ + ls 'C:\inetpub\logs' --glob '*.log' --recursive --depth 3 + +# One path's properties +java -jar winrm-java-standalone.jar ... stat 'C:\Windows\Temp\collect.log' + +# Content to stdout +java -jar winrm-java-standalone.jar ... cat 'C:\Windows\Temp\collect.log' +java -jar winrm-java-standalone.jar ... cat 'D:\logs\huge.log' --offset -8192 # the last 8 KiB +java -jar winrm-java-standalone.jar ... cat 'D:\logs\huge.log' --offset 1073741824 --length 65536 +java -jar winrm-java-standalone.jar ... cat 'C:\legacy\report.txt' --charset windows-1252 + +# Whole file to a local file, digest-verified +java -jar winrm-java-standalone.jar ... get 'C:\Windows\Temp\collect.log' ./collect.log +``` + +### `ls` + +`ls` lists the entries of a directory — or, with `--recursive` or `--depth`, the whole tree, +depth-first — one line per entry, **as the host walks the tree**: each line is written and +flushed as it arrives, so a pipe starts working immediately and memory stays bounded. The format +is fixed and independent of the locale: + +```text +d----- 0 2026-01-02T03:04:05.6789012Z C:\inetpub\logs\LogFiles +-a---- 1048576 2026-01-02T03:04:05.6789012Z C:\inetpub\logs\LogFiles\u_ex260101.log +``` + +1. The mode, like the `Mode` column of Windows PowerShell: `d` directory, `a` archive, `r` + read-only, `h` hidden, `s` system, `l` reparse point (a junction or a symbolic link), `-` + otherwise. +2. The size in bytes (0 for a directory), right-aligned on 12 characters. +3. The last modification time: ISO-8601 UTC with the 100 ns precision of Windows file times, + always 28 characters, so it sorts as a string. +4. The full path, as the host reports it: the rest of the line. + +Every filter is evaluated on the host, so only the matching entries travel. The filters select +what is *reported*, not where the walk goes: with `--recursive`, every subdirectory is traversed, +whatever the glob. Reparse points are listed, never descended into, so a junction looping back to +its parent cannot make the walk run forever. + +A subdirectory that cannot be read (typically, access denied) does not stop the walk: its path is +reported on stderr (`winrm-java: cannot read directory C:\...`), the rest of the tree is listed, +and `ls` then exits with `1`. The directory named on the command line must be readable: otherwise +`ls` fails. An administrator's WinRM session holds the backup privilege, so directory permissions +mostly stop other accounts. + +With `--json`, each entry is one UTF-8 JSON object per line +([JSON Lines](https://jsonlines.org/)): + +```json +{"path":"C:\\inetpub\\logs\\LogFiles\\u_ex260101.log","mode":"-a----","attributes":32,"size":1048576,"lastModified":"2026-01-02T03:04:05.6789012Z","created":"2025-12-01T08:00:00.0000000Z","lastAccessed":"2026-01-02T03:04:05.6789012Z"} +``` + +`attributes` is the raw Windows `FileAttributes` value, for the flags the mode leaves out (e.g. +`2048` compressed, `16384` encrypted). `attributes` and `size` are numbers; the timestamps have +the format above, so they compare correctly as strings — `jq 'select(.lastModified > +"2026-01-01")'`. The text output encodes the paths for the local console, which may not +represent every character; `--json` is always UTF-8. + +### `stat` + +`stat` prints the properties of one file or directory, one `name: value` per line, with the +fields and formats of `ls --json` (which `stat` also accepts as `--json`): + +```text +path: C:\Windows\Temp\collect.log +mode: -a---- +attributes: 32 +size: 1048576 +lastModified: 2026-01-02T03:04:05.6789012Z +created: 2025-12-01T08:00:00.0000000Z +lastAccessed: 2026-01-02T03:04:05.6789012Z +``` + +A path that does not exist exits with `66`, any other failure (access denied, for example) with +`70`: `stat` doubles as an existence test. + +### `cat` + +`cat` writes the **bytes** of the file to stdout as they arrive — no charset conversion, no +newline translation — so binary content and redirection both work: `... cat 'C:\x.bin' > x.bin` +produces a byte-exact copy in POSIX shells and in cmd.exe. **Not in Windows PowerShell 5.1**, +whose `>` decodes a native program's output as text and writes it back re-encoded: use `get` +there, or run the redirection in cmd.exe. PowerShell 7.4 and later keep the bytes. + +* `--offset ` starts at byte `n`. A negative offset counts from the end: `--offset -8192` reads + the last 8 KiB, the size being read by the same remote invocation that seeks, so a growing log + is tailed from its current end. The seek happens on the host: the end of a huge log costs the + same as its start. An offset past the end reads nothing. +* `--length ` reads at most `n` bytes, from the offset or from the start of the file. +* `--charset ` decodes the file with that charset and prints the text in the local + console's encoding, instead of the bytes: the way to read a file whose encoding is not the + local one. A range boundary can split a multibyte character, printed as `U+FFFD`. + +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 +configuration files, not bulk data. + +### `get` + +`get` downloads the whole file to a local file: +[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. + +### Quoting remote paths + +Remote paths reach the host untouched (the client quotes them for PowerShell itself), but the +local shell parses them first: + +* **POSIX shells** (bash, zsh, Git Bash): single-quote Windows paths — + `'C:\path with spaces\x.log'`, `'\\server\share\x.log'`. Inside double quotes, `\\` becomes `\` + and `\"` is a literal quote: `"\\server\share"` loses a backslash, and `"C:\logs\"` does not end + where it seems. +* **cmd.exe and Windows PowerShell 5.1**: a backslash just before a closing double quote escapes + the quote — `"C:\my logs\"` arrives as `C:\my logs"`, merged with the arguments that follow. + This happens with PowerShell's single quotes too (`'C:\my logs\'`), which it turns into double + quotes for a path with spaces. Leave the trailing backslash out (`"C:\my logs"`), or double it + (`"C:\my logs\\"`). + ## Timeout semantics `-t`/`--timeout` follows the operation: -* 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 command itself and any file - uploads. +* For `wql`, `ls`, and `cat`, it is the **inactivity timeout** of the stream — the longest + tolerated silence between two server responses. A large result or file can stream for longer + than the timeout, as long as the server keeps answering; a walking `ls` signals it is alive + every second. +* For `command`, `stat`, and `get`, it is the **overall deadline** covering the whole operation + (for `command`, the command itself and any file uploads). * For `shell`, it bounds **each protocol round trip**; an idle interactive session never trips it - (see [Interactive shell](#Interactive_shell)). + (see [Interactive shell](#interactive-shell)). See [Timeouts and Errors](timeouts-and-errors.html) for the underlying semantics. @@ -181,11 +346,14 @@ See [Timeouts and Errors](timeouts-and-errors.html) for the underlying semantics | Exit code | Meaning | | ---: | --- | -| `0` | Successful WQL query or remote command. | -| `0`–`255` | Remote command exit code, when it fits in that range. | +| `0` | Success. | +| `0`–`255` | Remote command exit code (`command`, `shell`), when it fits in that range. | +| `1` | `ls`: some directories could not be read (reported on stderr); the rest of the tree was listed. | | `64` | Invalid CLI usage. | +| `66` | Remote path not found (`ls`, `stat`, `cat`, `get`). | | `69` | Connection, DNS, socket, or TLS failure. | -| `70` | WinRM protocol or other remote failure (including a remote exit code not representable in 0–255). | +| `70` | WinRM protocol or other remote failure (including access denied to a remote path, and a remote exit code not representable in 0–255). | +| `74` | Local I/O failure: stdout closed or not writable, or the local file of `get` cannot be written. | | `77` | Authentication failure. | | `124` | Operation timeout. | diff --git a/src/site/markdown/files.md b/src/site/markdown/files.md index 0279996..f7f0659 100644 --- a/src/site/markdown/files.md +++ b/src/site/markdown/files.md @@ -9,7 +9,8 @@ WinRM has no file-access operation of its own (nothing like SFTP's `READ`), so t 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 it as it arrives. No SMB, no extra port, no share. This is the reverse direction of -[File Transfers](file-transfers.html). +[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. ## Reading a file diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java index b3f7139..28e91b9 100644 --- a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java +++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java @@ -7,9 +7,11 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -238,6 +240,90 @@ void acceptsEveryCommandAlias() throws Exception { } } + @Test + void parsesTheFileSubcommandsAndTheirOptions() throws Exception { + final String[] base = { "-h", "host", "-u", "user", "-p", "secret" }; + try ( + CliArguments parsed = CliArguments.parse( + concat( + base, + "ls", + "C:\\inetpub\\logs", + "--glob", + "*.log", + "--recursive", + "--depth=3", + "--files-only", + "--modified-after", + "2026-01-31T12:00:00+02:00", + "--min-size", + "1024", + "--json" + ) + )) { + assertEquals(CliArguments.Operation.LS, parsed.operation()); + assertEquals("C:\\inetpub\\logs", parsed.input()); + assertEquals("*.log", parsed.glob()); + assertTrue(parsed.recursive()); + assertEquals(3, parsed.depth()); + assertTrue(parsed.filesOnly()); + assertFalse(parsed.directoriesOnly()); + assertEquals(Instant.parse("2026-01-31T10:00:00Z"), parsed.modifiedAfter()); + assertEquals(1024, parsed.minSize()); + assertTrue(parsed.json()); + } + // Options may come before the path; a date alone stands for midnight UTC. + try ( + CliArguments parsed = CliArguments.parse( + concat(base, "ls", "--directories-only", "--modified-after=2026-01-31", "D:\\data") + )) { + assertEquals("D:\\data", parsed.input()); + assertTrue(parsed.directoriesOnly()); + assertEquals(Instant.parse("2026-01-31T00:00:00Z"), parsed.modifiedAfter()); + assertFalse(parsed.recursive()); + assertEquals(0, parsed.depth()); + assertNull(parsed.glob()); + assertFalse(parsed.json()); + } + try (CliArguments parsed = CliArguments.parse(concat(base, "stat", "C:\\x.log", "--json"))) { + assertEquals(CliArguments.Operation.STAT, parsed.operation()); + assertTrue(parsed.json()); + } + // A negative offset is the option's value, not an option. + try ( + CliArguments parsed = CliArguments.parse( + concat(base, "cat", "D:\\logs\\huge.log", "--offset", "-8192", "--length", "65536", "--charset", "windows-1252") + )) { + assertEquals(CliArguments.Operation.CAT, parsed.operation()); + assertEquals(-8192, parsed.offset()); + assertEquals(65_536, parsed.length()); + assertEquals(Charset.forName("windows-1252"), parsed.charset()); + } + // --length alone reads from the start, like head -c; nothing set reads the whole file as bytes. + try (CliArguments parsed = CliArguments.parse(concat(base, "cat", "C:\\x.bin", "--length=10"))) { + assertEquals(0, parsed.offset()); + assertEquals(10, parsed.length()); + } + try (CliArguments parsed = CliArguments.parse(concat(base, "cat", "C:\\x.bin"))) { + assertEquals(-1, parsed.length()); + assertNull(parsed.charset()); + } + try ( + CliArguments parsed = CliArguments + .parse(concat(base, "get", "C:\\Windows\\Temp\\collect.log", "logs/collect.log"))) { + assertEquals(CliArguments.Operation.GET, parsed.operation()); + assertEquals("C:\\Windows\\Temp\\collect.log", parsed.input()); + assertEquals(Path.of("logs/collect.log"), parsed.localFile()); + } + try (CliArguments parsed = CliArguments.parse(concat(base, "get", "C:\\Windows\\Temp\\collect.log"))) { + assertNull(parsed.localFile()); + } + // Remote paths are passed through untouched: spaces, UNC, a trailing backslash. + try (CliArguments parsed = CliArguments.parse(concat(base, "ls", "\\\\server\\share\\my logs\\"))) { + assertEquals("\\\\server\\share\\my logs\\", parsed.input()); + } + } + @Test void stripsExactlyOneFinalPasswordFileLineEnding() throws Exception { final Path passwordFile = temporaryDirectory.resolve("password.txt"); @@ -331,7 +417,41 @@ void rejectsInvalidArguments() { }, { "-P must be between 1 and 65535", concat(base, "-P", "65536", "command", "whoami") }, { "-t must be greater than zero", concat(base, "-t", "0", "command", "whoami") }, - { "missing subcommand (wql, command, or shell)", base }, + { "missing subcommand (wql, command, shell, ls, stat, cat, or get)", base }, + { "ls requires one remote path", concat(base, "ls") }, + { "ls requires one remote path", concat(base, "ls", "C:\\a", "C:\\b") }, + { "stat requires one remote path", concat(base, "stat", " ") }, + { "cat requires one remote path", concat(base, "cat", "--offset", "1") }, + { "get requires a remote path and an optional local path", concat(base, "get", "C:\\a", "a", "b") }, + { "get: invalid local path", concat(base, "get", "C:\\a", "a" + (char) 0 + "b") }, + { "--depth must be greater than zero", concat(base, "ls", "C:\\a", "--depth", "0") }, + { "--depth must be a number", concat(base, "ls", "C:\\a", "--depth", "three") }, + { + "--modified-after must be an ISO-8601 date or date-time with an offset, e.g. 2026-01-31 or 2026-01-31T12:00:00Z", + concat(base, "ls", "C:\\a", "--modified-after", "2026-01-31T12:00:00") + }, + { + "--modified-after must be an ISO-8601 date or date-time with an offset, e.g. 2026-01-31 or 2026-01-31T12:00:00Z", + concat(base, "ls", "C:\\a", "--modified-after", "yesterday") + }, + { "--min-size must be a number", concat(base, "ls", "C:\\a", "--min-size", "1M") }, + { + "--files-only and --directories-only are mutually exclusive", + concat(base, "ls", "C:\\a", "--files-only", "--directories-only") + }, + { "--glob requires a value", concat(base, "ls", "C:\\a", "--glob") }, + { "--glob requires the ls subcommand", concat(base, "cat", "C:\\a", "--glob", "*.log") }, + { "--json requires the ls or stat subcommand", concat(base, "cat", "C:\\a", "--json") }, + { "--offset requires the cat subcommand", concat(base, "get", "C:\\a", "--offset", "1") }, + { "--offset must be a number", concat(base, "cat", "C:\\a", "--offset", "end") }, + { "--length must be greater than zero", concat(base, "cat", "C:\\a", "--length", "0") }, + { + "--charset must name a charset known to Java, e.g. UTF-8 or windows-1252", + concat(base, "cat", "C:\\a", "--charset", "klingon") + }, + { "unknown option '-r'", concat(base, "ls", "C:\\a", "-r") }, + { "--directory requires the command or shell subcommand", concat(base, "-d", "C:\\build", "ls", "C:\\a") }, + { "--env requires the command or shell subcommand", concat(base, "--env", "A=b", "cat", "C:\\a") }, { "wql requires a query", concat(base, "wql", "") }, { "missing required option --hostname", diff --git a/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java b/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java index 97a805d..96686f0 100644 --- a/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java +++ b/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java @@ -18,6 +18,8 @@ void writesOrderedEscapedJsonValues() throws Exception { row.put("Label", "Café 東京"); row.put("Path", "C:\\Windows\nSystem32"); row.put("Missing", null); + // Numbers (file sizes, attributes) are JSON numbers; WQL values are always strings + row.put("Size", 5_000_000_000L); final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (PrintStream output = new PrintStream(bytes, true, StandardCharsets.US_ASCII.name())) { @@ -25,7 +27,8 @@ void writesOrderedEscapedJsonValues() throws Exception { } assertEquals( - "{\"Name\":\"Spooler\",\"Label\":\"Café 東京\",\"Path\":\"C:\\\\Windows\\nSystem32\",\"Missing\":null}" + + "{\"Name\":\"Spooler\",\"Label\":\"Café 東京\",\"Path\":\"C:\\\\Windows\\nSystem32\",\"Missing\":null,\"Size\":5000000000}" + + System.lineSeparator(), bytes.toString(StandardCharsets.UTF_8.name()) ); diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index d7ef394..c2d5df2 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -29,15 +29,29 @@ import static org.metricshub.winrm.light.FakeWsmanResponses.enqueueShellDeletion; import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.io.PrintStream; import java.net.ConnectException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.metricshub.winrm.light.FakeWsmanResponses; import org.metricshub.winrm.light.FakeWsmanServer; @@ -48,6 +62,13 @@ class WinRmCliTest { private static final String KERBEROS_KDC_PROPERTY = "java.security.krb5.kdc"; private static final String KERBEROS_REALM_PROPERTY = "java.security.krb5.realm"; + private static final String COMMAND_ID = "CMD-1"; + private static final String DIR = "C:\\inetpub\\logs"; + + /** 2026-01-02T03:04:05.6789012Z as a Windows file time, and as the CLI prints it. */ + private static final long FILETIME = 134_117_966_456_789_012L; + private static final String TIMESTAMP = "2026-01-02T03:04:05.6789012Z"; + @Test void helpAndVersionDoNotConnect() throws Exception { final Invocation help = invoke(new String[] { "--help" }, arguments -> failingRemote()); @@ -59,6 +80,9 @@ void helpAndVersionDoNotConnect() throws Exception { assertTrue(help.stdout.contains("--env ")); assertTrue(help.stdout.contains("--kerberos-kdc")); assertTrue(help.stdout.contains("--kerberos-realm")); + assertTrue(help.stdout.contains("[options] ls ")); + assertTrue(help.stdout.contains("[options] get []")); + assertTrue(help.stdout.contains("--modified-after ")); // The details (streaming behavior, password files, exit codes) live in the online manual. assertTrue(help.stdout.contains("https://metricshub.org/winrm-java/cli.html")); assertEquals("", help.stderr); @@ -602,6 +626,433 @@ void decodesCommandOutputAsUtf8WhateverTheRemoteLocale() throws Exception { } } + @Test + void lsStreamsTheLongFormatAndExitsWith1WhenADirectoryCannotBeRead() throws Exception { + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + final String denied = "! " + b64(DIR + "\\W3SVC1\\private") + " " + b64("Access is denied.") + "\n"; + enqueueShellCreation(server); + // The second entry arrives in a later Receive than the first. + enqueueScript( + server, + 0, + directoryRecord(DIR + "\\W3SVC1") + denied.substring(0, 10), + denied.substring(10) + fileRecord(DIR + "\\W3SVC1\\u_ex260101.log", 5_000_000_000L) + ); + enqueueShellDeletion(server); + + // Record how far the exchange had gone when the first entry reached standard output. + final int[] requestsAtFirstEntry = { -1 }; + final ByteArrayOutputStream stdout = new ByteArrayOutputStream() { + @Override + public synchronized void write(final byte[] bytes, final int offset, final int length) { + if (requestsAtFirstEntry[0] < 0) { + requestsAtFirstEntry[0] = server.decryptedRequests().size(); + } + super.write(bytes, offset, length); + } + }; + final Invocation invocation = invokeAgainst(server, stdout, "ls", DIR, "--recursive"); + + assertEquals(WinRmCli.EXIT_PARTIAL, invocation.exitCode); + assertEquals( + "d----- 0 " + TIMESTAMP + " " + DIR + "\\W3SVC1" + System.lineSeparator() + + "-a---- 5000000000 " + TIMESTAMP + " " + DIR + "\\W3SVC1\\u_ex260101.log" + System.lineSeparator(), + invocation.stdout + ); + assertEquals( + "winrm-java: cannot read directory " + DIR + "\\W3SVC1\\private" + System.lineSeparator(), + invocation.stderr + ); + // Streamed: the first entry was written before the second Receive was even sent + // (Create, Command, one Receive). + assertEquals(3, requestsAtFirstEntry[0]); + assertTrue(sentScripts(server).get(0).contains(";$md=" + Integer.MAX_VALUE + ";"), sentScripts(server).get(0)); + } + } + + @Test + void lsFiltersReachTheHostAndJsonCarriesEveryField() throws Exception { + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueShellCreation(server); + enqueueScript(server, 0, fileRecord(DIR + "\\a.log", 10) + "\n"); + enqueueShellDeletion(server); + + final Invocation invocation = invokeAgainst( + server, + "ls", + "--glob", + "*.log", + DIR, + "--depth=3", + "--files-only", + "--modified-after", + "2026-01-01", + "--min-size", + "1024", + "--json" + ); + + assertEquals(0, invocation.exitCode); + assertEquals(json(DIR + "\\a.log", "-a----", 32, 10) + System.lineSeparator(), invocation.stdout); + assertEquals("", invocation.stderr); + final long after = 116_444_736_000_000_000L + + Instant.parse("2026-01-01T00:00:00Z").getEpochSecond() * 10_000_000L; + final String script = sentScripts(server).get(0); + assertTrue(script.contains(b64(DIR)), script); + assertTrue( + script.contains( + "FromBase64String('" + b64("^.*\\.log$") + "'));$k=1;$mn=1024;$mx=" + Long.MAX_VALUE + ";$ta=" + after + + ";$tb=" + Long.MAX_VALUE + ";$md=3;" + ), + script + ); + } + } + + @Test + void statPrintsOneFieldPerLineOrJson() throws Exception { + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueShellCreation(server); + enqueueScript(server, 0, directoryRecord(DIR).replace("F 16 ", "F 1046 ")); + enqueueShellDeletion(server); + + final Invocation invocation = invokeAgainst(server, "stat", DIR); + + assertEquals(0, invocation.exitCode); + // 1046 = 0x416: a hidden system directory that is a reparse point (a junction) + assertEquals( + String.join( + System.lineSeparator(), + "path: " + DIR, + "mode: d--hsl", + "attributes: 1046", + "size: 0", + "lastModified: " + TIMESTAMP, + "created: " + TIMESTAMP, + "lastAccessed: " + TIMESTAMP + ) + + System.lineSeparator(), + invocation.stdout + ); + assertTrue(sentScripts(server).get(0).contains(b64(DIR)), sentScripts(server).get(0)); + } + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueShellCreation(server); + enqueueScript(server, 0, fileRecord(DIR + "\\a.log", 10)); + enqueueShellDeletion(server); + + final Invocation invocation = invokeAgainst(server, "stat", DIR + "\\a.log", "--json"); + + assertEquals(0, invocation.exitCode); + assertEquals(json(DIR + "\\a.log", "-a----", 32, 10) + System.lineSeparator(), invocation.stdout); + } + } + + @Test + void aMissingRemotePathExitsWith66() throws Exception { + // stat: the library reports a missing path as an empty result + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueShellCreation(server); + enqueueScript(server, 2, ""); + enqueueShellDeletion(server); + + final Invocation invocation = invokeAgainst(server, "stat", DIR + "\\missing.log"); + + assertEquals(WinRmCli.EXIT_NOT_FOUND, invocation.exitCode); + assertEquals("", invocation.stdout); + assertEquals( + "winrm-java: Remote path not found on 127.0.0.1: " + DIR + "\\missing.log" + System.lineSeparator(), + invocation.stderr + ); + } + // cat, ls, get: as a failure + for (final String subcommand : List.of("cat", "ls", "get")) { + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueShellCreation(server); + enqueueScript(server, 2, ""); + enqueueShellDeletion(server); + + final Invocation invocation = invokeAgainst(server, subcommand, DIR + "\\missing.log"); + + assertEquals(WinRmCli.EXIT_NOT_FOUND, invocation.exitCode, subcommand); + assertEquals( + "winrm-java: Remote path not found on 127.0.0.1: " + DIR + "\\missing.log" + System.lineSeparator(), + invocation.stderr, + subcommand + ); + } + } + } + + @Test + void catWritesTheRemoteBytesUnconverted() throws Exception { + // Every byte value, then sequences any text round trip would alter: CRLF, lone LF and CR, + // invalid UTF-8, a UTF-16 byte order mark, Ctrl+Z. + final byte[] content = new byte[256 + 9]; + for (int i = 0; i < 256; i++) { + content[i] = (byte) i; + } + 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)); + enqueueShellDeletion(server); + + final Invocation invocation = invokeAgainst(server, "cat", "C:\\Windows\\Temp\\collect.bin"); + + assertEquals(0, invocation.exitCode); + assertArrayEquals(content, invocation.stdoutBytes); + assertEquals("", invocation.stderr); + // The whole file + assertTrue(sentScripts(server).get(0).contains("$n=[long]0;$l=[long]-1;"), sentScripts(server).get(0)); + } + } + + @Test + 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"); + enqueueShellDeletion(server); + + final Invocation invocation = invokeAgainst( + server, + "cat", + "C:\\legacy\\report.txt", + "--offset", + "-8192", + "--length", + "1024", + "--charset", + "windows-1252" + ); + + assertEquals(0, invocation.exitCode); + // Decoded as windows-1252, printed in the local console's encoding (UTF-8 here) + assertEquals("café", invocation.stdout); + assertTrue(sentScripts(server).get(0).contains("$n=[long]-8192;$l=[long]1024;"), sentScripts(server).get(0)); + } + } + + @Test + void aClosedStandardOutputStopsTheRemoteRead() throws Exception { + try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { + enqueueShellCreation(server); + // The first block of a longer file: the read is still running... + server + .enqueue(200, FakeWsmanResponses.envelope(FakeWsmanResponses.commandResponse(COMMAND_ID))) + .enqueue( + 200, + FakeWsmanResponses.envelope(FakeWsmanResponses.receiveResponse(stdoutStream(b64(new byte[] + { 1, 2, 3 }) + "\r\n"), 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 + .enqueue( + 500, + FakeWsmanResponses + .fault( + "2150858793", + "The WS-Management service cannot complete the operation within the time specified in OperationTimeout." + ) + ); + enqueueShellDeletion(server); + + // What "| head" does to the standard output once it has seen enough + final OutputStream closed = new OutputStream() { + @Override + public void write(final int b) throws IOException { + throw new IOException("The pipe is being closed"); + } + }; + final Invocation invocation = invokeAgainst(server, closed, "cat", "D:\\logs\\huge.log"); + + assertEquals(WinRmCli.EXIT_IO, invocation.exitCode); + assertEquals("winrm-java: cannot write to standard output" + System.lineSeparator(), invocation.stderr); + assertEquals(1, server.decryptedRequests().stream().filter(r -> r.contains("")).count()); + assertTrue(server.decryptedRequests().stream().anyMatch(r -> r.contains(" sentScripts(final FakeWsmanServer server) { + final Pattern encoded = Pattern.compile("-EncodedCommand ([A-Za-z0-9+/=]+)"); + return server + .decryptedRequests() + .stream() + .map(encoded::matcher) + .filter(Matcher::find) + .map(m -> new String(Base64.getDecoder().decode(m.group(1)), StandardCharsets.UTF_16LE)) + .collect(Collectors.toList()); + } + + private static String[] fakeServerOptions(final FakeWsmanServer server) { + return new String[] { + "-h", + "127.0.0.1", + "-P", + String.valueOf(server.port()), + "-u", + "FAKE\\user", + "-p", + "secret", + "-t", + "30000" }; + } + + /** Run the CLI through its real connect factory against the fake server. */ + private static Invocation invokeAgainst(final FakeWsmanServer server, final String... subcommand) throws Exception { + return invokeAgainst(server, new ByteArrayOutputStream(), subcommand); + } + + /** + * Run the CLI through its real connect factory against the fake server, writing its standard output to the given + * stream. + */ + private static Invocation invokeAgainst( + final FakeWsmanServer server, + final OutputStream stdoutTarget, + final String... subcommand + ) + throws Exception { + final ByteArrayOutputStream stderrBytes = new ByteArrayOutputStream(); + try ( + PrintStream stdout = new PrintStream(stdoutTarget, true, StandardCharsets.UTF_8.name()); + PrintStream stderr = new PrintStream(stderrBytes, true, StandardCharsets.UTF_8.name())) { + final int exitCode = WinRmCli.run( + concat(fakeServerOptions(server), subcommand), + stdout, + stderr, + WinRmCli::connect, + () -> { + throw new AssertionError("No password prompt expected"); + } + ); + return new Invocation( + exitCode, + stdoutTarget instanceof ByteArrayOutputStream + ? ((ByteArrayOutputStream) stdoutTarget).toByteArray() : new byte[0], + stderrBytes.toString(StandardCharsets.UTF_8.name()) + ); + } + } + private static WinRmCli.RemoteOperations failingRemote() { throw new AssertionError("No connection expected"); } @@ -627,11 +1078,7 @@ private static Invocation invoke( PrintStream stdout = new PrintStream(stdoutBytes, true, StandardCharsets.UTF_8.name()); PrintStream stderr = new PrintStream(stderrBytes, true, StandardCharsets.UTF_8.name())) { final int exitCode = WinRmCli.run(arguments, stdout, stderr, factory, passwordReader); - return new Invocation( - exitCode, - stdoutBytes.toString(StandardCharsets.UTF_8.name()), - stderrBytes.toString(StandardCharsets.UTF_8.name()) - ); + return new Invocation(exitCode, stdoutBytes.toByteArray(), stderrBytes.toString(StandardCharsets.UTF_8.name())); } } @@ -655,11 +1102,7 @@ private static Invocation invoke( }, localInput ); - return new Invocation( - exitCode, - stdoutBytes.toString(StandardCharsets.UTF_8.name()), - stderrBytes.toString(StandardCharsets.UTF_8.name()) - ); + return new Invocation(exitCode, stdoutBytes.toByteArray(), stderrBytes.toString(StandardCharsets.UTF_8.name())); } } @@ -681,12 +1124,14 @@ private static void restoreProperty(final String name, final String value) { private static final class Invocation { private final int exitCode; + private final byte[] stdoutBytes; private final String stdout; private final String stderr; - private Invocation(final int exitCode, final String stdout, final String stderr) { + private Invocation(final int exitCode, final byte[] stdoutBytes, final String stderr) { this.exitCode = exitCode; - this.stdout = stdout; + this.stdoutBytes = stdoutBytes; + this.stdout = new String(stdoutBytes, StandardCharsets.UTF_8); this.stderr = stderr; } } @@ -753,6 +1198,11 @@ public int shell( return commandExitCode; } + @Override + public org.metricshub.winrm.RemoteFile file(final String path) { + throw new AssertionError("No remote file access expected"); + } + @Override public void close() { closed = true; From f9bd2dc84bcfd91021fe0413781f9195efb05b05 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Sat, 26 Sep 2026 02:08:22 +0200 Subject: [PATCH 3/3] Address Codex review: README unchanged, blank --glob, exit 74 wording - README: the new example is removed. README changes only when a change alters what it already shows (AGENTS.md); cli.md covers the subcommands. - A blank --glob value (e.g. an empty shell variable) is now a usage error (64), like every other file option, instead of reaching the library and exiting 70. - cli.md: exit code 74 covers file-system errors on the local file of get; a plain IOException such as a full disk keeps its message but exits 69. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 -------- src/main/java/org/metricshub/winrm/cli/CliArguments.java | 3 +++ src/site/markdown/cli.md | 2 +- .../java/org/metricshub/winrm/cli/CliArgumentsTest.java | 1 + 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5e2faab..a40ab5d 100644 --- a/README.md +++ b/README.md @@ -260,14 +260,6 @@ java -jar target/winrm-java--standalone.jar \ -h server.example.net -u 'DOMAIN\user' -pf password.txt shell ``` -Print the last 8 KiB of a remote log (`ls`, `stat`, and `get` list, describe, and download -remote files): - -```bash -java -jar target/winrm-java--standalone.jar \ - -h server.example.net -u 'DOMAIN\user' -pf password.txt cat 'D:\logs\app.log' --offset -8192 -``` - Use `--help` for the option list and `--version` for the build version. The CLI is built on the streaming API: WQL rows are written to stdout as UTF-8 [JSON Lines](https://jsonlines.org/) **as the enumeration pages arrive**, and remote command stdout and stderr are forwarded **live** to the diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 5099c3c..8f523fe 100644 --- a/src/main/java/org/metricshub/winrm/cli/CliArguments.java +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -315,6 +315,9 @@ private static int parseFileOption(final Builder builder, final String[] argumen case "--glob": requireSubcommand(builder, option, Operation.LS); builder.glob = optionValue(arguments, index, option); + if (isBlank(builder.glob)) { + throw new CliUsageException(option + " requires a value"); + } return nextIndex(argument, index); case "--recursive": requireSubcommand(builder, option, Operation.LS); diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md index 8f85e35..6af11e7 100644 --- a/src/site/markdown/cli.md +++ b/src/site/markdown/cli.md @@ -353,7 +353,7 @@ See [Timeouts and Errors](timeouts-and-errors.html) for the underlying semantics | `66` | Remote path not found (`ls`, `stat`, `cat`, `get`). | | `69` | Connection, DNS, socket, or TLS failure. | | `70` | WinRM protocol or other remote failure (including access denied to a remote path, and a remote exit code not representable in 0–255). | -| `74` | Local I/O failure: stdout closed or not writable, or the local file of `get` cannot be written. | +| `74` | Local I/O failure: stdout closed or not writable, or a file-system error on the local file of `get` (access denied, a missing drive). | | `77` | Authentication failure. | | `124` | Operation timeout. | diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java index 28e91b9..4c23d95 100644 --- a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java +++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java @@ -440,6 +440,7 @@ void rejectsInvalidArguments() { concat(base, "ls", "C:\\a", "--files-only", "--directories-only") }, { "--glob requires a value", concat(base, "ls", "C:\\a", "--glob") }, + { "--glob requires a value", concat(base, "ls", "C:\\a", "--glob", " ") }, { "--glob requires the ls subcommand", concat(base, "cat", "C:\\a", "--glob", "*.log") }, { "--json requires the ls or stat subcommand", concat(base, "cat", "C:\\a", "--json") }, { "--offset requires the cat subcommand", concat(base, "get", "C:\\a", "--offset", "1") },