diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index a26ff5c..d38a70a 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -154,7 +154,7 @@ private interface StdinSource { * shell's command-line limit (room for the {@code CMD.EXE /C} wrapper included, so * the decision does not depend on {@link #upload(Path...)}) */ - private static String encodePowerShell(final String script) { + static String encodePowerShell(final String script) { final String encoded = POWERSHELL_PREFIX + Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE)); return encoded.length() > MAX_COMMAND_LINE_LENGTH - CMD_WRAPPER_LENGTH ? null : encoded; diff --git a/src/main/java/org/metricshub/winrm/RemoteFile.java b/src/main/java/org/metricshub/winrm/RemoteFile.java new file mode 100644 index 0000000..79200cb --- /dev/null +++ b/src/main/java/org/metricshub/winrm/RemoteFile.java @@ -0,0 +1,318 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.metricshub.winrm.exceptions.WinRMClientException; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; + +/** + * A file on the remote host, obtained with {@link WinRMClient#file(String)}: read its content — + * whole, as a byte range, or as a stream — or compute its digest. Nothing is sent until a + * terminal ({@link #readBytes()}, {@link #readText(Charset)}, {@link #openStream()}, + * {@link #openReader(Charset)}, {@link #digest(String)}) is called. + * + *
{@code
+ * byte[] content = client.file("C:\\Windows\\Temp\\collect.bin").readBytes();
+ *
+ * // The last 8 KiB of a log: one seek, no scan
+ * String tail = client.file("D:\\logs\\huge.log").offset(-8192).readText(StandardCharsets.UTF_8);
+ *
+ * try (BufferedReader reader = client.file("D:\\logs\\huge.log").openReader(StandardCharsets.UTF_8)) {
+ * reader.lines().filter(l -> l.contains("ERROR")).forEach(System.out::println);
+ * }
+ * }
+ * + * The content travels through the WinRM connection itself (no SMB, no extra port): a small + * PowerShell script on the host writes the bytes base64-encoded, which is binary-safe and + * independent of the remote console code page. It is designed for configuration files, logs and + * small data files — not a bulk transport: base64 through a command shell is far slower + * than SMB. The host needs PowerShell (2.0 or later) in {@code FullLanguage} mode. + *
+ * The file is opened with a share mode that tolerates other writers, so a log being written by a + * running service can be read; a file held with an exclusive lock (e.g. {@code pagefile.sys}) + * fails with a sharing violation. A read writes nothing on the host: its script travels on the + * command line, which limits the path to about 1,450 characters (fewer with non-Latin + * characters) — a longer path fails before anything is sent. Ranges are byte ranges, and + * reads are not snapshots: a + * file that grows or shrinks between two reads is read as it is at each read. + *
+ * A request is not thread-safe; configure it and call its terminals from one thread. + */ +public final class RemoteFile { + + /** Default cap of {@link #readBytes()} and {@link #readText(Charset)}: 64 MiB. */ + public static final long DEFAULT_MAX_BYTES = 64L * 1024 * 1024; + + /** The largest byte array the JVM reliably allocates. */ + private static final long MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8L; + + private final WinRMClient client; + private final String path; + private long offset; + private long length = -1; + private long maxBytes = DEFAULT_MAX_BYTES; + private Duration timeout; + + /** + * Create the request. + * + * @param client the client the file is read through + * @param path the path of the file on the remote host + */ + RemoteFile(final WinRMClient client, final String path) { + Utils.checkNonBlank(path, "path"); + this.client = client; + this.path = path; + this.timeout = client.defaultTimeout(); + } + + /** + * Get the path of the file on the remote host. + * + * @return the path, as given to {@link WinRMClient#file(String)} + */ + public String path() { + return path; + } + + /** + * Start reading at the given byte position instead of the beginning of the file. A + * negative offset counts from the end: {@code offset(-n)} starts at + * {@code max(0, size - n)}, the size being read by the same remote invocation that seeks (so a + * growing log is tailed from its current end); a file shorter than {@code n} is read whole. An + * offset past the end of the file reads nothing — it is not an error. Applies to every read + * terminal, not to {@link #digest(String)}. + * + * @param offset the byte position; negative counts from the end of the file + * @return this request + */ + public RemoteFile offset(final long offset) { + this.offset = offset; + return this; + } + + /** + * Read at most the given number of bytes (from the {@link #offset(long)}, once resolved) + * instead of reading to the end of the file. A length beyond the end of the file reads what + * exists; a zero length reads nothing. Applies to every read terminal, not to + * {@link #digest(String)}. + * + * @param length how many bytes to read at most (zero or more) + * @return this request + * @throws IllegalArgumentException when the length is negative + */ + public RemoteFile length(final long length) { + if (length < 0) { + throw new IllegalArgumentException("length must not be negative."); + } + this.length = length; + return this; + } + + /** + * Set the size cap of {@link #readBytes()} and {@link #readText(Charset)}, which hold the whole + * content in memory: reading more than the cap fails instead of risking an + * {@link OutOfMemoryError}. Defaults to {@link #DEFAULT_MAX_BYTES} (64 MiB). The cap is also + * sent to the host, so no more than one byte past it is ever transferred. For larger content, + * use {@link #openStream()}, which reads with bounded memory and has no cap. + * + * @param maxBytes the largest content accepted, in bytes + * @return this request + * @throws IllegalArgumentException when the cap is negative or exceeds the largest possible + * byte array + */ + public RemoteFile maxBytes(final long maxBytes) { + if (maxBytes < 0 || maxBytes >= MAX_ARRAY_SIZE) { + throw new IllegalArgumentException("maxBytes must be between 0 and " + (MAX_ARRAY_SIZE - 1) + "."); + } + this.maxBytes = maxBytes; + return this; + } + + /** + * Override the client's timeout for this request. For the blocking terminals + * ({@link #readBytes()}, {@link #readText(Charset)}, {@link #digest(String)}) it is a + * wall-clock deadline for the whole read; for {@link #openStream()} and + * {@link #openReader(Charset)} it is an inactivity timeout, the longest silence + * tolerated from the host between two chunks of content. + * + * @param timeout the timeout (at least one millisecond) + * @return this request + */ + public RemoteFile timeout(final Duration timeout) { + this.timeout = WinRMClient.checkPositive(timeout, "timeout"); + return this; + } + + /** + * Read the content (the whole file, or the configured range) into memory, byte-exact: nothing + * is converted, and a byte order mark is kept. + * + * @return the content + * @throws WinRMClientException when the content exceeds {@link #maxBytes(long)}, or the file + * cannot be read (not found, a directory, access denied, sharing violation, PowerShell + * unavailable or constrained) + * @throws WinRMTimeoutException when the timeout elapses first + */ + public byte[] readBytes() { + final long requested = length < 0 ? maxBytes + 1 : Math.min(length, maxBytes + 1); + return blocking(() -> { + try (InputStream in = open(requested)) { + final byte[] content = in.readNBytes((int) requested); + if (content.length > maxBytes) { + throw new WinRMClientException( + String.format( + "Remote file %s on %s exceeds the %d-byte cap of readBytes(): raise maxBytes(...), read a range, or use openStream()", + path, + client.hostname(), + maxBytes + ) + ); + } + // A read that filled exactly the requested length stops short of the end of the + // stream: drain it (the host sends nothing past the length) so the script's exit code + // is still checked and a late failure is not reported as a successful read. + in.transferTo(OutputStream.nullOutputStream()); + return content; + } + }); + } + + /** + * Read the content (the whole file, or the configured range) into memory and decode it with the + * given charset — no guessing, no default. This method strips nothing: a byte order mark is + * handled exactly as the given charset's decoder handles it — {@code UTF-8} keeps it (as + * {@code U+FEFF}), while {@code UTF-16} consumes it to detect the byte order. A range boundary + * may split a multibyte character, which then + * decodes to {@code U+FFFD} at the edges, like any malformed input. + * + * @param charset the charset the file is encoded with + * @return the decoded content + * @throws WinRMClientException when the content exceeds {@link #maxBytes(long)}, or the file + * cannot be read + * @throws WinRMTimeoutException when the timeout elapses first + */ + public String readText(final Charset charset) { + Utils.checkNonNull(charset, "charset"); + return new String(readBytes(), charset); + } + + /** + * Open the content (the whole file, or the configured range) as a stream, decoded as it + * arrives: memory is bounded by one transfer block, not by the file, and there is no size cap. + * The file is opened before this method returns, so a file that cannot be read fails here; a + * failure midway is reported by {@code read()} (never as a silently short read). + *
+ * The stream must be closed — use try-with-resources. It holds the client's connection
+ * until it reaches its end or is closed; closing it early stops the remote read. Failures are
+ * reported through the unchecked {@link WinRMClientException} hierarchy, including from
+ * {@code read()}. The timeout is an inactivity timeout (see {@link #timeout(Duration)}).
+ *
+ * @return the content stream, to use with try-with-resources
+ * @throws WinRMClientException when the file cannot be read
+ * @throws WinRMTimeoutException when the host does not answer in time
+ */
+ public InputStream openStream() {
+ return open(length);
+ }
+
+ /**
+ * Open the content (the whole file, or the configured range) as a character stream decoded
+ * with the given charset: {@link #openStream()} behind an {@link InputStreamReader}, with the
+ * same lifecycle — close it. Like {@link #readText(Charset)}, it strips nothing: a byte
+ * order mark is handled as the given charset's decoder handles it.
+ *
+ * @param charset the charset the file is encoded with
+ * @return the reader, to use with try-with-resources
+ * @throws WinRMClientException when the file cannot be read
+ * @throws WinRMTimeoutException when the host does not answer in time
+ */
+ public BufferedReader openReader(final Charset charset) {
+ Utils.checkNonNull(charset, "charset");
+ return new BufferedReader(new InputStreamReader(openStream(), charset));
+ }
+
+ /**
+ * Compute the digest of the whole file on the host — nothing but the digest is transferred.
+ * The {@link #offset(long)} and {@link #length(long)} settings do not apply.
+ *
+ * @param algorithm {@code MD5}, {@code SHA1}, {@code SHA256}, {@code SHA384} or {@code SHA512}
+ * @return the digest, as lowercase hexadecimal
+ * @throws IllegalArgumentException when the algorithm is not supported
+ * @throws WinRMClientException when the file cannot be read
+ * @throws WinRMTimeoutException when the timeout elapses first
+ */
+ public String digest(final String algorithm) {
+ Utils.checkNonNull(algorithm, "algorithm");
+ final String script = RemoteFiles.digestScript(path, algorithm);
+ return blocking(() -> {
+ try (InputStream in = RemoteFiles.open(client, path, script, timeout)) {
+ final StringBuilder hex = new StringBuilder();
+ for (final byte b : in.readAllBytes()) {
+ hex.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
+ }
+ return hex.toString();
+ }
+ });
+ }
+
+ /** Start the remote read of the configured offset and the given length ({@code -1}: to the end). */
+ private InputStream open(final long readLength) {
+ return RemoteFiles.open(client, path, RemoteFiles.readScript(path, offset, readLength), timeout);
+ }
+
+ /**
+ * Run a blocking terminal under the wall-clock deadline: a worker runs the exchange and is
+ * cancelled when the deadline fires, exactly like {@link CommandRequest#execute()}.
+ */
+ private
+ * Base64 is what makes a text console a binary-safe channel: every byte value survives, and the
+ * output is pure ASCII, so recovering the exact bytes never depends on the remote console code
+ * page. Each line is an independent base64 block, decoded on its own. The caller-supplied path is
+ * embedded base64-encoded too (UTF-8), so no path ever needs quoting or escaping.
+ *
+ * Failures are reported by the script's exit code, checked when the output ends (see the
+ * {@code EXIT_*} constants), and turned into a {@link WinRMClientException} naming the cause.
+ */
+final class RemoteFiles {
+
+ private RemoteFiles() {}
+
+ /** The path does not exist. */
+ static final int EXIT_NOT_FOUND = 2;
+
+ /** The file is held with an exclusive lock by another process (sharing violation). */
+ static final int EXIT_SHARING_VIOLATION = 3;
+
+ /** Access to the path is denied. */
+ static final int EXIT_ACCESS_DENIED = 4;
+
+ /** PowerShell runs in Constrained Language Mode: the .NET calls the scripts rely on are blocked. */
+ static final int EXIT_CONSTRAINED_LANGUAGE = 5;
+
+ /** The path is a directory, not a file. */
+ static final int EXIT_IS_DIRECTORY = 6;
+
+ /** The exit code of {@code cmd.exe} when {@code powershell.exe} cannot be found. */
+ static final int EXIT_COMMAND_NOT_FOUND = 9009;
+
+ /**
+ * Bytes read and written per base64 line: 49,149, a multiple of 3 so a full block encodes
+ * without padding, into 65,532 characters — with the newline, a 65,533-byte line that fits in
+ * exactly two of the WinRM service's output reads. The service's shell plugin reads a
+ * command's stdout pipe with at most one 32 KiB read per timer tick (64 per second at the
+ * default 15.625 ms), so each line should fill whole reads: a 57 KiB block (a 77,825-byte
+ * line) needs three, and is measurably slower.
+ */
+ static final int BLOCK_SIZE = 49_149;
+
+ /**
+ * The hash algorithms {@link #digestScript(String, String)} accepts: the names .NET's
+ * {@code HashAlgorithm.Create} knows on every Windows version (PowerShell 2.0 included). The
+ * name is embedded in the script, hence the closed list.
+ */
+ static final Set
+ * Each line goes to the raw stdout stream as ONE write: the WinRM service reads the pipe at
+ * most once per timer tick, taking only what is in the pipe, and {@code [Console]::Out} (an
+ * auto-flushing writer with a small buffer) cuts a line into 256-byte writes that leave each
+ * read with about 4 KiB — 5 to 6 times slower (measured on Windows 2008 R2 and 2022).
+ */
+ private static final String READ_RANGE = "try{$n=[long]%d;$l=[long]%d;" +
+ "if($n -lt 0){$n=[Math]::Max([long]0,$f.Length+$n)};" +
+ "$f.Position=$n;$b=New-Object byte[] %d;$o=[Console]::OpenStandardOutput();" +
+ "while($l -ne 0){$c=$b.Length;if($l -gt 0 -and $l -lt $c){$c=[int]$l};" +
+ "$r=$f.Read($b,0,$c);if($r -le 0){break};" +
+ "$a=[Text.Encoding]::ASCII.GetBytes([Convert]::ToBase64String($b,0,$r)+\"`n\");" +
+ "$o.Write($a,0,$a.Length);if($l -gt 0){$l-=$r}};" +
+ "$o.Flush()}catch{fail $_.Exception}finally{$f.Close()}";
+
+ /** Hash the whole file and write the raw digest as one base64 line. {@code %s} is the algorithm. */
+ private static final String DIGEST = "try{$h=[Security.Cryptography.HashAlgorithm]::Create('%s');" +
+ "[Console]::Out.WriteLine([Convert]::ToBase64String($h.ComputeHash($f)))}catch{fail $_.Exception}finally{$f.Close()}";
+
+ /**
+ * Build the script writing a byte range of the file.
+ *
+ * @param path the remote file
+ * @param offset the byte position to start from; negative counts from the end of the file
+ * @param length how many bytes to read at most, or {@code -1} to read to the end
+ * @return the PowerShell script
+ */
+ static String readScript(final String path, final long offset, final long length) {
+ return openFile(path) + String.format(READ_RANGE, offset, length, BLOCK_SIZE);
+ }
+
+ /**
+ * Build the script writing the digest of the file.
+ *
+ * @param path the remote file
+ * @param algorithm one of {@link #DIGEST_ALGORITHMS}
+ * @return the PowerShell script
+ */
+ static String digestScript(final String path, final String algorithm) {
+ if (!DIGEST_ALGORITHMS.contains(algorithm)) {
+ throw new IllegalArgumentException(
+ "Unsupported digest algorithm: " + algorithm + " (supported: " +
+ DIGEST_ALGORITHMS.stream().sorted().collect(Collectors.joining(", ")) + ")"
+ );
+ }
+ return openFile(path) + String.format(DIGEST, algorithm);
+ }
+
+ private static String openFile(final String path) {
+ return String.format(OPEN_FILE, Base64.getEncoder().encodeToString(path.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ /**
+ * Start the script and return the stream of the bytes it writes. The first line is fetched
+ * before returning, so a file that cannot be opened fails here, not on the first read.
+ *
+ * @param client the client to run the script on
+ * @param path the remote file, for the error messages
+ * @param script the script, from {@link #readScript} or {@link #digestScript}
+ * @param timeout the inactivity timeout of the stream
+ * @return the decoded stream; it must be closed
+ */
+ static InputStream open(final WinRMClient client, final String path, final String script, final Duration timeout) {
+ if (CommandRequest.encodePowerShell(script) == null) {
+ // powerShell(...) would transparently fall back to uploading the script as a file: a read
+ // must stay read-only on the host (no files written, no certutil), so refuse instead.
+ throw new WinRMClientException(
+ String.format(
+ "Remote path too long to read on %s (%d characters): the reader script must fit the remote command line",
+ client.hostname(),
+ path.length()
+ )
+ );
+ }
+ final RemoteProcess process = client.powerShell(script).timeout(timeout).start();
+ try {
+ return new DecodingStream(process, path, client.hostname());
+ } catch (final RuntimeException e) {
+ process.close();
+ throw e;
+ }
+ }
+
+ /**
+ * Build the exception reporting a non-zero exit code of a script.
+ *
+ * @param exitCode the script's exit code
+ * @param path the remote file
+ * @param hostname the remote host
+ * @param stderr what the script wrote on stderr
+ * @return the exception to throw
+ */
+ static WinRMClientException failure(
+ final int exitCode,
+ final String path,
+ final String hostname,
+ final String stderr
+ ) {
+ switch (exitCode) {
+ case EXIT_NOT_FOUND:
+ return new WinRMClientException(String.format("Remote file not found on %s: %s", hostname, path));
+ case EXIT_SHARING_VIOLATION:
+ return new WinRMClientException(
+ String.format(
+ "Remote file %s on %s is locked by another process (sharing violation): it cannot be read",
+ path,
+ hostname
+ )
+ );
+ case EXIT_ACCESS_DENIED:
+ return new WinRMClientException(String.format("Access denied to remote file %s on %s", path, hostname));
+ case EXIT_CONSTRAINED_LANGUAGE:
+ return new WinRMClientException(
+ String.format(
+ "PowerShell runs in Constrained Language Mode on %s (e.g. AppLocker or WDAC): remote file access needs FullLanguage",
+ hostname
+ )
+ );
+ case EXIT_IS_DIRECTORY:
+ return new WinRMClientException(String.format("Remote path %s on %s is a directory, not a file", path, hostname));
+ case EXIT_COMMAND_NOT_FOUND:
+ return new WinRMClientException(
+ String.format("PowerShell is not available on %s: remote file access needs powershell.exe", hostname)
+ );
+ default:
+ return new WinRMClientException(
+ String.format(
+ "Failed to read remote file %s on %s (exit code %d)%s",
+ path,
+ hostname,
+ exitCode,
+ stderr.isBlank() ? "" : ": " + stderr.strip()
+ )
+ );
+ }
+ }
+
+ /**
+ * The bytes a script writes, decoded line by line as the output arrives: memory is bounded by
+ * one line, not by the file. The exit code is checked when the output ends, so a failure
+ * midway is reported instead of looking like a short file.
+ */
+ private static final class DecodingStream extends InputStream {
+
+ private final RemoteProcess process;
+ private final BufferedReader stdout;
+ private final String path;
+ private final String hostname;
+ private byte[] block = new byte[0];
+ private int position;
+ private boolean ended;
+
+ private DecodingStream(final RemoteProcess process, final String path, final String hostname) {
+ this.process = process;
+ this.stdout = process.stdout();
+ this.path = path;
+ this.hostname = hostname;
+ fill();
+ }
+
+ @Override
+ public int read() {
+ return fill() ? block[position++] & 0xFF : -1;
+ }
+
+ @Override
+ public int read(final byte[] buffer, final int offset, final int length) {
+ Objects.checkFromIndexSize(offset, length, buffer.length);
+ if (length == 0) {
+ return 0;
+ }
+ if (!fill()) {
+ return -1;
+ }
+ final int count = Math.min(length, block.length - position);
+ System.arraycopy(block, position, buffer, offset, count);
+ position += count;
+ return count;
+ }
+
+ @Override
+ public int available() {
+ return block.length - position;
+ }
+
+ /** Make sure unread bytes are buffered: {@code false} at the end of a successful output. */
+ private boolean fill() {
+ while (position == block.length) {
+ if (ended) {
+ return false;
+ }
+ final String line = readLine();
+ if (line == null) {
+ end();
+ return false;
+ }
+ final String data = line.strip();
+ if (data.isEmpty()) {
+ continue;
+ }
+ try {
+ block = Base64.getDecoder().decode(data);
+ } catch (final IllegalArgumentException e) {
+ throw new WinRMClientException(
+ String.format(
+ "Unexpected output while reading remote file %s on %s: %s",
+ path,
+ hostname,
+ data.length() > 80 ? data.substring(0, 80) + "..." : data
+ ),
+ e
+ );
+ }
+ position = 0;
+ }
+ return true;
+ }
+
+ private String readLine() {
+ try {
+ return stdout.readLine();
+ } catch (final IOException e) {
+ // Unreachable: the reader reports failures unchecked (see RemoteProcess).
+ throw new WinRMClientException(e.getMessage(), e);
+ }
+ }
+
+ /** The output ended: collect the exit code, release the connection, report a failure. */
+ private void end() {
+ ended = true;
+ final int exitCode = process.waitFor();
+ final String stderr = exitCode == 0 ? "" : process.stderr().lines().collect(Collectors.joining("\n"));
+ process.close();
+ if (exitCode != 0) {
+ throw failure(exitCode, path, hostname, stderr);
+ }
+ }
+
+ @Override
+ public void close() {
+ ended = true;
+ block = new byte[0];
+ position = 0;
+ process.close();
+ }
+ }
+}
diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java
index 159ffbb..8503f1d 100644
--- a/src/main/java/org/metricshub/winrm/WinRMClient.java
+++ b/src/main/java/org/metricshub/winrm/WinRMClient.java
@@ -170,6 +170,25 @@ public CommandRequest powerShell(final String script) {
return new CommandRequest(this, script, true);
}
+ /**
+ * Prepare access to a file on the remote host: read its content (whole, as a byte range, or as
+ * a stream) or compute its digest, through the WinRM connection itself. Nothing is sent until
+ * a terminal of the returned {@link RemoteFile} is called.
+ *
+ * {@code
+ * byte[] content = client.file("C:\\Windows\\Temp\\collect.bin").readBytes();
+ * String tail = client.file("D:\\logs\\huge.log").offset(-8192).readText(StandardCharsets.UTF_8);
+ * }
+ *
+ * @param path the absolute path of the file on the remote host, e.g.
+ * {@code C:\Windows\Temp\collect.log}
+ * @return the remote file, to configure and read
+ * @throws IllegalArgumentException when the path is blank
+ */
+ public RemoteFile file(final String path) {
+ return new RemoteFile(this, path);
+ }
+
/**
* Copy a local file to an explicit path on the remote host, through the WinRM connection
* itself (no SMB, no extra port). The transfer is digest-verified and skipped when the
diff --git a/src/site/markdown/file-transfers.md b/src/site/markdown/file-transfers.md
index 9e0a7b8..10bba1d 100644
--- a/src/site/markdown/file-transfers.md
+++ b/src/site/markdown/file-transfers.md
@@ -177,5 +177,6 @@ Notes:
## See also
+* [Remote Files](files.html) — the other direction: reading remote files through the WinRM channel
* [Remote Commands](commands.html) — the command builder that carries the transfer
* [Preparing the Windows Host](preparing-the-host.html) — the privileges a transfer needs
diff --git a/src/site/markdown/files.md b/src/site/markdown/files.md
new file mode 100644
index 0000000..13dcc5e
--- /dev/null
+++ b/src/site/markdown/files.md
@@ -0,0 +1,127 @@
+keywords: remote file, read file, byte range, tail, digest, base64, powershell, stream
+description: How the WinRM Java Client reads files on the remote host through the WinRM channel itself — whole files, byte ranges, tails, streams, and digests.
+
+# Remote Files
+
+
+
+WinRM has no file-access operation of its own (nothing like SFTP's `READ`), so the client reads
+remote files **through the WinRM command shell**: a small PowerShell script opens the file on the
+host and writes the requested bytes base64-encoded, and the client decodes them as they arrive.
+No SMB, no extra port, no share. This is the reverse direction of
+[File Transfers](file-transfers.html).
+
+## Reading a file
+
+[`client.file(path)`](apidocs/org/metricshub/winrm/RemoteFile.html) designates a file on the
+remote host; nothing is sent until a terminal is called:
+
+```java
+// Whole file, bytes (byte-exact: nothing converted, a BOM is kept)
+byte[] content = client.file("C:\\Windows\\Temp\\collect.bin").readBytes();
+
+// Whole file, text — the charset is always explicit, never guessed
+String text = client.file("C:\\inetpub\\logs\\u_ex260729.log").readText(StandardCharsets.UTF_8);
+
+// Integrity: only the digest is transferred (MD5, SHA1, SHA256, SHA384, SHA512)
+String sha256 = client.file("C:\\Windows\\Temp\\collect.ps1").digest("SHA256");
+```
+
+## Byte ranges and tails
+
+`offset(long)` and `length(long)` select a byte range, served by a **seek** on the host — reading
+the end of a multi-gigabyte log costs the same as reading the start of a small one:
+
+```java
+// 64 KiB from the 1 GiB position
+byte[] chunk = client.file("D:\\logs\\huge.log").offset(1_073_741_824L).length(65_536).readBytes();
+
+// The last 8 KiB — a negative offset counts from the end
+byte[] tail = client.file("D:\\logs\\huge.log").offset(-8192).readBytes();
+```
+
+| Setting | Result |
+|---|---|
+| `offset(-n)` | starts at `max(0, size - n)`: a file shorter than `n` is read whole. The size is read by the same remote invocation that seeks, so a growing log is tailed from its current end. |
+| `offset(-8192).length(1024)` | the first 1 KiB of the last 8 KiB |
+| offset past the end | empty — not an error |
+| `length` beyond the end | what exists |
+| `length(0)`, an empty file | empty |
+
+Ranges are **byte** ranges: a range boundary can split a multibyte character, which then decodes
+to `U+FFFD` at the edges of a `readText`. Reads are not snapshots: a file that changes between two
+reads is read as it is at each read.
+
+## Streaming large files
+
+`readBytes()` and `readText()` hold the whole content in memory, so they are capped:
+**64 MiB by default**, adjustable with `maxBytes(long)` — reading more fails with a clear
+exception instead of an `OutOfMemoryError` (the cap is sent to the host too, so no more than one
+byte past it is transferred). For anything larger, stream:
+
+```java
+try (InputStream in = client.file("D:\\logs\\huge.log").openStream()) {
+ in.transferTo(out);
+}
+
+try (BufferedReader reader = client.file("D:\\logs\\huge.log").openReader(StandardCharsets.UTF_8)) {
+ reader.lines().filter(line -> line.contains("ERROR")).forEach(System.out::println);
+}
+```
+
+The stream decodes the content block by block as it arrives (48 KiB per block): memory stays
+bounded whatever the file size. Like a [`RemoteProcess`](commands.html), **it must be closed** —
+it holds the client's connection until it reaches its end or is closed, and closing it early stops
+the remote read. The file is opened before `openStream()` returns, so a missing file fails there;
+a failure midway is reported by `read()`, never as a silently short read.
+
+## Timeouts
+
+The blocking terminals (`readBytes`, `readText`, `digest`) run under a **wall-clock deadline**:
+the client's timeout, or `timeout(Duration)` on the request — raise it for large reads.
+`openStream()`/`openReader()` use the **inactivity** semantics of the other streaming terminals:
+the timeout bounds the silence between two blocks, not the whole read. See
+[Timeouts and Errors](timeouts-and-errors.html).
+
+## Locked files, errors and requirements
+
+* The file is opened with a share mode that tolerates other writers, so **a log being written by
+ a running service can be read**. A file held with an exclusive lock (`pagefile.sys`, live
+ registry hives) fails with a *sharing violation*.
+* Every failure is a `WinRMClientException` naming its cause: file not found, the path is a
+ directory, access denied, sharing violation, PowerShell not available, or PowerShell in
+ Constrained Language Mode.
+* The host needs **PowerShell 2.0 or later in `FullLanguage` mode** (Windows Server 2008 R2 and
+ later ship it). When AppLocker or WDAC puts PowerShell in Constrained Language Mode, the .NET
+ calls the reader relies on are blocked: remote file access is then not available.
+* Non-ASCII paths and content are safe: the path travels base64-encoded (UTF-8) inside the script
+ and the content comes back base64-encoded, so neither depends on the remote console code page.
+* A read never writes anything on the host: the reader script travels on the command line, which
+ limits the path to about **1,450 characters** (about 720 with accented letters, 480 with CJK
+ characters — well above the classic 260-character `MAX_PATH`). A longer path fails with a
+ `WinRMClientException` before anything is sent.
+
+## Performance
+
+The mechanism is designed for **configuration files, logs and small data files — not bulk data**.
+Measured over HTTP with NTLM encryption, `openStream()` reads about **1.4–1.5 MB/s**: a 20 MiB
+file in 14–15 seconds, on Windows Server 2008 R2 (PowerShell 2.0), 2019 and 2022 (PowerShell 5.1)
+alike. A small file costs about a second, mostly the PowerShell startup. For gigabytes, use SMB.
+
+The limit is on the host, not in the network or the client: the WinRM service reads a command's
+output pipe itself, **at most 32 KiB per read and about 60 reads per second** per output stream —
+about 2 MB/s of output whatever the command. The reader is shaped for it: each block of the file
+travels as one 65,533-byte base64 line that fills exactly two reads (a larger 77,825-byte line
+needing three reads was 20–25% slower). Neither the client's `MaxEnvelopeSize` nor the host's
+`MaxEnvelopeSizekb` changes the limit — raising them only makes each response larger and
+proportionally slower to come.
+
+The same limit applies to any command output: a command that writes its output in small pieces —
+line by line, like `Write-Output` or `type` — gets only what accumulated in the 4 KiB pipe at each
+read, about 0.3 MB/s. Parallel commands, each on its own `WinRMClient`, add up: each command gets
+its own output pipeline.
+
+## See also
+
+* [File Transfers](file-transfers.html) — the other direction: copying local files to the host
+* [Remote Commands](commands.html) — the command shell the reads ride
diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md
index 1ef7114..ee31e31 100644
--- a/src/site/markdown/index.md
+++ b/src/site/markdown/index.md
@@ -126,6 +126,7 @@ remain available and unchanged, with their checked exceptions.
* [WQL Queries](wql.html) — query WMI and read the result
* [Remote Commands](commands.html) — run commands and copy files to the host
* [File Transfers](file-transfers.html) — how files are copied through the WinRM channel
+* [Remote Files](files.html) — read remote files: whole, byte ranges, tails, streams, digests
* [Command-Line Client](cli.html) — the standalone jar's manual page
* [Authentication](authentication.html) — NTLM and Kerberos
* [TLS / HTTPS](tls.html) — certificate validation and trust stores
diff --git a/src/site/site.xml b/src/site/site.xml
index 2591475..38f54fc 100644
--- a/src/site/site.xml
+++ b/src/site/site.xml
@@ -57,6 +57,7 @@