Third issue of the remote file access family: the missing symmetric half of WinRMClient.uploadFile(Path, String). Builds on the ranged/streaming read of #146.
Context
client.uploadFile(Path localFile, String remoteFile) has existed since the smbj removal (#117): it pushes a local file through the WinRM channel itself, digest-verified, skipping the transfer when the destination already has identical content. There is no way back. Every caller that needs to retrieve a remote log or a command's output file has to shell out to type/Get-Content and hope the encoding survives.
downloadFile is the mirror image, and it should mirror the upload's guarantees, not just its direction: verified integrity, no wasted transfer, and no half-written local file left behind on failure.
Proposed API
Symmetric with the existing method, on the client:
client.downloadFile("C:\\Windows\\Temp\\collect.log", Path.of("collect.log"));
And as a terminal on the per-path request, for the fluent form:
long bytes = client.file("C:\\Windows\\Temp\\collect.log")
.downloadTo(Path.of("collect.log"));
Requirements
- Digest verification, both ways. Compute the remote digest with the probe
ShellFileCopy already has (certutil -hashfile, SHA256 with a SHA1 fallback — see CERTUTIL_ALGORITHMS), compare it with the digest of the received bytes, and fail with the same shape of WindowsRemoteException the upload path raises on a mismatch. Reuse ShellFileCopy.digestHex / parseAnyDigest rather than reimplementing.
- Skip an identical transfer, matching the upload's behavior: if the local destination already exists with the same digest, do not transfer, and say so (return value or documented no-op).
- Atomic destination: stream into a temporary file in the destination's directory,
fsync, then ATOMIC_MOVE onto the target. A failure or a timeout must never leave a truncated file at the destination path.
- Bounded memory: built on the streaming read, never
readBytes() into a byte[] first. A 500 MB file must download in constant memory (slowly — see below).
- Downloads are not resumable — decided, and deliberately out of scope: an interrupted download starts over. Resuming would mean tracking verified byte counts across attempts and re-validating that the remote file has not changed, which is more state machine than this transport's speed justifies. Say so in the Javadoc and in
file-transfers.md so callers do not expect otherwise.
- Reuse the quota-rejection retry (
isRetryableQuotaRejection, QUOTA_RETRIES, QUOTA_RETRY_DELAY_MILLIS): a long download hits the same WinRM operation quotas the upload does.
- Directory destination:
downloadFile(remote, Path.of("C:\\dir")) where the local path is an existing directory should write dir\<remote file name> — or reject it. Pick one, document it.
- Honest performance documentation. Base64 through a command shell is roughly an order of magnitude slower than SMB; publish a measured figure from the live host in
file-transfers.md and keep the existing "not a bulk transport" caveat prominent. Callers moving gigabytes should use SMB, and the docs should say so.
- Timeout is the client's wall-clock deadline for the blocking method; a large download will need an explicitly raised timeout, and the exception message must make the cause obvious (bytes transferred / total when it fired).
Tests & docs
- Round-trip test:
uploadFile then downloadFile returns byte-identical content, for a binary file with every byte value and for a file with a non-ASCII name.
FakeWsmanServer tests: digest mismatch → failure with no file at the destination; interruption mid-transfer → no partial file left behind and no half-written temporary file; identical-digest destination → no transfer performed.
WinRMLiveTest: download a real file from anaxagore and verify its digest.
file-transfers.md extended with the download direction, the integrity and atomicity guarantees, the measured throughput, and the SMB recommendation for bulk data; README snippet next to uploadFile.
Acceptance criteria
downloadFile retrieves byte-exact content, digest-verified against the remote host.
- No partial file is ever visible at the destination path — verified by a test that interrupts mid-transfer.
- An identical local file is not re-downloaded.
- A file substantially larger than the JVM heap downloads successfully.
mvn verify site green: no checkstyle/PMD/SpotBugs findings, full Javadoc, docs updated.
🤖 Generated with Claude Code
Context
client.uploadFile(Path localFile, String remoteFile)has existed since the smbj removal (#117): it pushes a local file through the WinRM channel itself, digest-verified, skipping the transfer when the destination already has identical content. There is no way back. Every caller that needs to retrieve a remote log or a command's output file has to shell out totype/Get-Contentand hope the encoding survives.downloadFileis the mirror image, and it should mirror the upload's guarantees, not just its direction: verified integrity, no wasted transfer, and no half-written local file left behind on failure.Proposed API
Symmetric with the existing method, on the client:
And as a terminal on the per-path request, for the fluent form:
Requirements
ShellFileCopyalready has (certutil -hashfile, SHA256 with a SHA1 fallback — seeCERTUTIL_ALGORITHMS), compare it with the digest of the received bytes, and fail with the same shape ofWindowsRemoteExceptionthe upload path raises on a mismatch. ReuseShellFileCopy.digestHex/parseAnyDigestrather than reimplementing.fsync, thenATOMIC_MOVEonto the target. A failure or a timeout must never leave a truncated file at the destination path.readBytes()into abyte[]first. A 500 MB file must download in constant memory (slowly — see below).file-transfers.mdso callers do not expect otherwise.isRetryableQuotaRejection,QUOTA_RETRIES,QUOTA_RETRY_DELAY_MILLIS): a long download hits the same WinRM operation quotas the upload does.downloadFile(remote, Path.of("C:\\dir"))where the local path is an existing directory should writedir\<remote file name>— or reject it. Pick one, document it.file-transfers.mdand keep the existing "not a bulk transport" caveat prominent. Callers moving gigabytes should use SMB, and the docs should say so.Tests & docs
uploadFilethendownloadFilereturns byte-identical content, for a binary file with every byte value and for a file with a non-ASCII name.FakeWsmanServertests: digest mismatch → failure with no file at the destination; interruption mid-transfer → no partial file left behind and no half-written temporary file; identical-digest destination → no transfer performed.WinRMLiveTest: download a real file fromanaxagoreand verify its digest.file-transfers.mdextended with the download direction, the integrity and atomicity guarantees, the measured throughput, and the SMB recommendation for bulk data; README snippet next touploadFile.Acceptance criteria
downloadFileretrieves byte-exact content, digest-verified against the remote host.mvn verify sitegreen: no checkstyle/PMD/SpotBugs findings, full Javadoc, docs updated.🤖 Generated with Claude Code