diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java index 409ebc5..8f523fe 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,142 @@ 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); + if (isBlank(builder.glob)) { + throw new CliUsageException(option + " requires a value"); + } + 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 +455,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 +595,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 +637,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 +719,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 +805,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/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/site/markdown/cli.md b/src/site/markdown/cli.md index 581545c..6af11e7 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 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/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/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(); diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java index b3f7139..4c23d95 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,42 @@ 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 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;