Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 308 additions & 9 deletions src/main/java/org/metricshub/winrm/RemoteFile.java

Large diffs are not rendered by default.

56 changes: 55 additions & 1 deletion src/main/java/org/metricshub/winrm/RemoteFiles.java
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,20 @@ private RemoteFiles() {}
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()}";

/**
* What a download is verified against, as one base64 line of {@link #PROBE_LENGTH} bytes: the
* number of bytes hashed (8 bytes, little-endian), then their SHA-256 digest. The count is the
* stream position after hashing, not the file length read beforehand, so both describe the same
* bytes even when the file changes during the probe.
*/
private static final String PROBE = "try{$h=[Security.Cryptography.HashAlgorithm]::Create('SHA256');$d=$h.ComputeHash($f);"
+
"[Console]::Out.WriteLine([Convert]::ToBase64String([byte[]]([BitConverter]::GetBytes($f.Position)+$d)))}" +
"catch{fail $_.Exception}finally{$f.Close()}";

/** The length of the {@link #probeScript(String)} output: an 8-byte size and a 32-byte SHA-256 digest. */
static final int PROBE_LENGTH = 40;

/**
* Build the script writing a byte range of the file.
*
Expand Down Expand Up @@ -243,6 +257,16 @@ static String digestScript(final String path, final String algorithm) {
return openFile(path) + String.format(DIGEST, algorithm);
}

/**
* Build the script writing the size and the SHA-256 digest of the file (see {@link #PROBE}).
*
* @param path the remote file
* @return the PowerShell script
*/
static String probeScript(final String path) {
return openFile(path) + PROBE;
}

private static String openFile(final String path) {
return String.format(OPEN_FILE, base64(path));
}
Expand Down Expand Up @@ -431,6 +455,11 @@ static InputStream open(final WinRMClient client, final String path, final Strin
* Start a script. It must fit the command line: {@code powerShell(...)} would transparently
* fall back to uploading it as a file, and file access must stay read-only on the host (no
* files written, no certutil), so a script too long is refused instead.
* <p>
* A start rejected by the host's operation quota ran nothing (see
* {@link ShellFileCopy#isRetryableQuotaRejection(Exception)}): it is retried with the escalating
* delays of the file transfers, as long as the retry starts within the timeout, counted from the
* first attempt.
*
* @param client the client to run the script on
* @param path the remote path, for the error messages
Expand All @@ -448,7 +477,32 @@ static RemoteProcess start(final WinRMClient client, final String path, final St
)
);
}
return client.powerShell(script).timeout(timeout).start();
final long begin = Utils.getCurrentTimeMillis();
for (int attempt = 0;; attempt++) {
try {
return client.powerShell(script).timeout(timeout).start();
} catch (final WinRMClientException e) {
final long delay = ShellFileCopy.QUOTA_RETRY_DELAY_MILLIS * (attempt + 1);
// No retry once the pause would reach the timeout: it must start within it.
if (attempt >= ShellFileCopy.QUOTA_RETRIES
||
!ShellFileCopy.isRetryableQuotaRejection(e)
||
Utils.getCurrentTimeMillis() - begin + delay >= WinRMClient.toMillis(timeout)) {
throw e;
}
try {
Utils.sleep(delay);
Comment thread
bertysentry marked this conversation as resolved.
} catch (final InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw e;
}
// The pause itself can overrun (a GC pause, a descheduled thread).
if (Utils.getCurrentTimeMillis() - begin >= WinRMClient.toMillis(timeout)) {
throw e;
}
}
}
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/org/metricshub/winrm/ShellFileCopy.java
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,14 @@ private ShellFileCopy() {}
private static final String FAULT_OPERATION_QUOTA = "2150859174";

/** How many times a transfer command is retried after an operation-quota rejection. */
private static final int QUOTA_RETRIES = 4;
static final int QUOTA_RETRIES = 4;

/**
* Base delay before retrying after an operation-quota rejection; each retry waits one step
* longer. Measured on Windows 2008 R2 (quota 15 per user): the budget fully recovers within
* 30 seconds, so the escalating delays (5+10+15+20&nbsp;s) comfortably bridge it.
*/
private static final long QUOTA_RETRY_DELAY_MILLIS = 5_000L;
static final long QUOTA_RETRY_DELAY_MILLIS = 5_000L;

/**
* Copy the specified local files to a temporary directory on the remote host through the
Expand Down Expand Up @@ -912,7 +912,7 @@ static String contentAddressedName(final String fileName, final byte[] content,
* @param maxLength Maximum length, in UTF-16 chars
* @return the truncated string
*/
private static String truncateAtCodePoint(final String value, final int maxLength) {
static String truncateAtCodePoint(final String value, final int maxLength) {
if (value.length() <= maxLength) {
return value;
}
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/org/metricshub/winrm/WinRMClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,27 @@ public void uploadFile(final Path localFile, final String remoteFile) {
}
}

/**
* Copy a file from the remote host to a local file, through the WinRM connection itself — the
* counterpart of {@link #uploadFile(Path, String)}, and a shorthand for
* {@code file(remoteFile).downloadTo(localFile)}. The transfer is digest-verified, skipped when
* the local file already has identical content, and atomic: the local file is replaced in one
* step, never left half-written. When {@code localFile} is an existing directory, the file is
* written into it under its remote name. The client's timeout applies, as a wall-clock deadline:
* raise it for large files (see {@link RemoteFile#downloadTo(Path)}).
*
* @param remoteFile the absolute path of the file on the remote host, e.g.
* {@code C:\Windows\Temp\collect.log}
* @param localFile the local file to write, or an existing directory to write the file into
* @return the number of bytes transferred: the size of the file, or 0 when the local file
* already had the same content
* @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the timeout elapses first
* @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure
*/
public long downloadFile(final String remoteFile, final Path localFile) {
return file(remoteFile).downloadTo(localFile);
}

/**
* Get the hostname this client connects to.
*
Expand Down
107 changes: 100 additions & 7 deletions src/site/markdown/file-transfers.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
keywords: file transfer, file copy, upload, certutil, base64, digest, content-addressed, temporary files
description: How the WinRM Java Client copies local files to the remote host through the WinRM channel itself — destination paths, temporary files, integrity verification, and command-line substitution.
keywords: file transfer, file copy, upload, download, certutil, base64, digest, content-addressed, temporary files, atomic
description: How the WinRM Java Client copies files to and from the remote host through the WinRM channel itself — destination paths, temporary files, integrity verification, atomic downloads, and command-line substitution.

# File Transfers

<!-- MACRO{toc|fromDepth=2|toDepth=3|id=toc} -->

The client can copy local files to the remote host **through the WinRM connection itself** — no
SMB, no TCP port 445, no administrative share — so it works from any client OS and needs no port
beyond the WinRM one. This page explains exactly how the transfer works: where files land, which
temporary files are created, how integrity is guaranteed, and how the command line is rewritten.
The client can copy local files to the remote host, and remote files back, **through the WinRM
connection itself** — no SMB, no TCP port 445, no administrative share — so it works from any
client OS and needs no port beyond the WinRM one. This page explains exactly how the transfers
work: where files land, which temporary files are created, how integrity is guaranteed, and how
the command line is rewritten. [Downloading a file](#downloading-a-file) covers the other
direction.

## The two entry points

Expand Down Expand Up @@ -175,8 +177,99 @@ Notes:
destination directory, and the host must provide `certutil` (transfer) and `forfiles`
(housekeeping). See [Preparing the Windows Host](preparing-the-host.html).

## Downloading a file

[`WinRMClient.downloadFile(...)`](apidocs/org/metricshub/winrm/WinRMClient.html) copies a remote
file to a local one — the counterpart of `uploadFile(...)`, with the same guarantees: the content
is verified, an identical copy is not transferred again, and a failure never leaves a truncated
file behind. The same terminal exists on the per-path request, where the timeout can be set:

```java
client.downloadFile("C:\\Windows\\Temp\\collect.log", Path.of("collect.log"));

long bytes = client.file("D:\\exports\\big.csv")
.timeout(Duration.ofMinutes(10))
.downloadTo(Path.of("big.csv")); // bytes transferred, 0 when the local copy was already identical
```

When the local path is an existing directory, the file is written into it under its remote name,
like `cp`: `downloadFile("C:\\Windows\\Temp\\collect.log", Path.of("logs"))` writes
`logs/collect.log`. A remote name with a colon — an alternate data stream (`a.txt:meta`) or a
drive-relative path (`C:a.txt`) — is refused there: name the local file explicitly. The
destination's directory is created when needed.

### How a download works

1. **Probe.** One PowerShell invocation on the host reports the size and the SHA-256 digest of
the file. It opens the file exactly like the [remote file reads](files.html) do, with a share
mode that tolerates other writers — so a log held open by a running service can be downloaded
(`certutil -hashfile`, which the uploads use, fails on such a file with a sharing violation).
2. **Skip check.** If the local destination already exists with the same size and digest, nothing
is transferred: the download returns 0.
3. **Transfer.** The file is read with [`openStream()`](files.html#streaming-large-files) and
written, block by block, to a temporary file **next to the destination**:
`<name>.<random>.part` (the name cut to 64 characters, so a long one still fits the file-name
limits). Memory stays bounded whatever the size of the file — a 64 MiB file was downloaded by a
JVM limited to a 32 MiB heap.
4. **Verify and publish.** The received bytes must match the probed size and digest; the
temporary file is then flushed to disk (`fsync`) and moved onto the destination in one atomic
step (`ATOMIC_MOVE`), replacing any previous file. On Linux and macOS a replaced file keeps its
permissions — the temporary file is created with them, so a `0600` file stays private
throughout. On Windows, the new file gets the permissions the directory gives new
files: an explicit ACL set on the replaced file is not carried over.

**The destination is never seen truncated or half-written**: until the final move it keeps its
previous content (or does not exist), and after it, it has the complete, verified content. On any
failure — a digest mismatch, a read error, a timeout — the destination is left as it was and the
temporary file is deleted as the transfer stops; only a process killed in the middle of a download
can leave a `.part` file behind.

The local copy is always **exactly the bytes the probe hashed**: one consistent version of the
remote file, never a mix of two. A change that reaches bytes not transferred yet — a log being
appended to, a file rewritten — fails the integrity check instead of delivering a torn copy. A
change confined to bytes already transferred leaves that consistent version in place, like any
copy of a file that changes after it was read.

Downloads are **not resumable**: a download that fails or times out starts over from the first
byte next time. Resuming would mean tracking verified byte ranges across attempts and proving
that the remote file did not change in between — more machinery than the speed of this transport
justifies.

The file is read-only on the host: a download writes nothing there, and needs what the remote file
reads need — PowerShell 2.0 or later in `FullLanguage` mode, and read access to the file (see
[Remote Files](files.html#errors-and-requirements)).

### Timeout

The timeout of a download is a **wall-clock deadline for the whole transfer**: the client's
timeout for `downloadFile(...)` (30 seconds by default), or `timeout(Duration)` on the request.
A large file needs a raised timeout — at the speed below, 30 seconds is about 40 MB. When the
deadline fires, the exception says how far the transfer got:

```text
Download of D:\exports\big.csv from server01 timed out after PT30S, 41943040 of 104857600 bytes
transferred: raise the timeout for large files
```

The deadline and the final move exclude each other: a timeout is only reported when the
destination was left untouched. A deadline that fires while the verified file is being moved into
place lets the move complete, and the download succeeds.

### Download performance

Measured over HTTP with NTLM encryption, a download runs at about **1.35–1.45 MB/s**: 20 MiB in
14.7 seconds on Windows Server 2022, 15.6 seconds on Windows Server 2008 R2 (PowerShell 2.0), and
64 MiB in 46 seconds on 2022 — probe included. A small file costs under a second (two PowerShell
invocations: the probe and the read), and skipping an identical copy about 0.4 seconds. The limit
is on the host, in the way the WinRM service forwards a command's output — see
[Read performance](files.html#read-performance).

That is one to two orders of magnitude slower than SMB on a local network: **downloads are not a
bulk transport either**. They suit logs, configuration files and command results; to move
gigabytes, use SMB (or any file-transfer protocol the host offers).

## See also

* [Remote Files](files.html) — the other direction: reading and listing remote files through the WinRM channel
* [Remote Files](files.html) — reading remote files (whole, byte ranges, streams) and listing directories 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
31 changes: 25 additions & 6 deletions src/site/markdown/files.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
keywords: remote file, read file, byte range, tail, digest, base64, powershell, stream, directory listing, file properties, exists, glob
description: How the WinRM Java Client reads files and lists directories on the remote host through the WinRM channel itself — whole files, byte ranges, tails, streams, digests, file properties, and filtered recursive listings.
keywords: remote file, read file, download, byte range, tail, digest, base64, powershell, stream, directory listing, file properties, exists, glob
description: How the WinRM Java Client reads files and lists directories on the remote host through the WinRM channel itself — whole files, byte ranges, tails, streams, downloads, digests, file properties, and filtered recursive listings.

# Remote Files

Expand Down Expand Up @@ -75,11 +75,26 @@ it holds the client's connection until it reaches its end or is closed, and clos
the remote read. The file is opened before `openStream()` returns, so a missing file fails there;
a failure midway is reported by `read()`, never as a silently short read.

## Downloading to a local file

`downloadTo(Path)` writes the whole file to a local file — digest-verified, skipped when the local
copy is already identical, and atomic: the destination is replaced in one step, never left
half-written. `client.downloadFile(remote, local)` does the same with the client's timeout:

```java
long bytes = client.file("C:\\Windows\\Temp\\collect.log").downloadTo(Path.of("collect.log"));
```

It streams, so memory stays bounded whatever the size of the file, and its timeout is a
wall-clock deadline for the whole transfer. The mechanics, the guarantees and the measured speed
are described in [File Transfers](file-transfers.html#downloading-a-file).

## Timeouts

The blocking terminals (`readBytes`, `readText`, `digest`, `info`, `exists`, and `execute()` on a
[directory listing](#listing-a-directory)) run under a **wall-clock deadline**: the client's
timeout, or `timeout(Duration)` on the request — raise it for large reads and big trees.
The blocking terminals (`readBytes`, `readText`, `downloadTo`, `digest`, `info`, `exists`, and
`execute()` on a [directory listing](#listing-a-directory)) run under a **wall-clock deadline**:
the client's timeout, or `timeout(Duration)` on the request — raise it for large reads, downloads
and big trees.
`openStream()`/`openReader()` and a listing's `stream()` use the **inactivity** semantics of the
other streaming terminals: the timeout bounds the silence between two blocks, not the whole
operation. See [Timeouts and Errors](timeouts-and-errors.html).
Expand All @@ -92,6 +107,10 @@ operation. See [Timeouts and Errors](timeouts-and-errors.html).
* Every failure is a `WinRMClientException` naming its cause: path not found, a directory where a
file is expected (or a file given to `list()`), access denied, sharing violation, PowerShell not
available, or PowerShell in Constrained Language Mode.
* A script rejected at startup by the host's per-user operation quota (as low as 15 concurrent
operations on Windows Server 2008 R2) has not run: it is retried with escalating delays (5, 10,
15, 20 seconds) for as long as the timeout allows, like the steps of a
[file transfer](file-transfers.html).
* The host needs **PowerShell 2.0 or later in `FullLanguage` mode** (Windows Server 2008 R2 and
later ship it). When AppLocker or WDAC puts PowerShell in Constrained Language Mode, the .NET
calls the scripts rely on are blocked: remote file access is then not available.
Expand Down Expand Up @@ -255,6 +274,6 @@ DMTF strings.

## See also

* [File Transfers](file-transfers.html) — the other direction: copying local files to the host
* [File Transfers](file-transfers.html) — copying local files to the host, and how downloads work
* [Remote Commands](commands.html) — the command shell the reads and listings ride
* [WQL Queries](wql.html) — the WMI alternative for file metadata
7 changes: 4 additions & 3 deletions src/site/markdown/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ The **WinRM Java Client** is a small library that talks to the Windows Remote Ma
* **execute remote commands** — `cmd.exe` command lines or PowerShell scripts — capturing standard
output, standard error and the exit code, optionally copying local script files to the host
first ([Remote Commands](commands.html)), and
* **access remote files** — read a file (whole, a byte range, a tail), get its properties, or
list a directory with filters evaluated on the host ([Remote Files](files.html)).
* **access remote files** — read a file (whole, a byte range, a tail), download it to a local
file, get its properties, or list a directory with filters evaluated on the host
([Remote Files](files.html)).

All of them can also **stream**: WQL rows are consumed page by page as they arrive
(`stream()`), command output is consumed while the command is still running (`start()`,
Expand Down Expand Up @@ -134,7 +135,7 @@ remain available and unchanged, with their checked exceptions.
and the privileges the account needs
* [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
* [File Transfers](file-transfers.html) — how files are copied to the host and downloaded back through the WinRM channel
* [Remote Files](files.html) — read remote files (whole, byte ranges, tails, streams, digests), get file properties, list directories
* [Command-Line Client](cli.html) — the standalone jar's manual page
* [Authentication](authentication.html) — NTLM and Kerberos
Expand Down
Loading
Loading