Skip to content

Remote file access (3/4): downloadFile(remote, local) — the symmetric, digest-verified counterpart of uploadFile #147

Description

@bertysentry

Third issue of the remote file access family: the missing symmetric half of WinRMClient.uploadFile(Path, String). Builds on openStream() from #146 (the foundation) — it does not need the listing of #145.

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"));

Flow — one remote digest probe

  1. Probe the remote digest with ShellFileCopy.digestProbe / parseAnyDigest.
  2. If the local destination exists with the same digest → no transfer, return.
  3. Otherwise client.file(remote).openStream() → DigestInputStream → temporary file in the destination's directory.
  4. Compare the received digest with the probed one; on match fsync and ATOMIC_MOVE onto the target. On any failure (mismatch, timeout, interruption) delete the temporary file.

A file modified between the probe and the transfer shows up as a digest mismatch — the correct outcome.

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 writes dir\<remote file name>, like cp — decided (the CLI get default destination in Remote file access (4/4): CLI ls, stat, cat and get subcommands #148 needs the same resolution). 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions