Skip to content

Remote file access (2/4): read remote file content — whole file, byte ranges (offset/length), text with charset, streaming #146

Description

@bertysentry

Second issue of the remote file access family, building on the client.file(path) entry point, the RemoteFileInfo type and the base64-framed PowerShell helper introduced by #145.

Context

Listing tells you a remote file is there; this issue reads its bytes. Same channel as the upload path in ShellFileCopy, run in reverse: the remote side base64-encodes the requested byte range, the client decodes it as the output chunks arrive.

Two properties make base64 the right framing rather than an accident of history:

The cost is the 4/3 inflation plus the shell's own overhead: this is a mechanism for configuration files, logs and small data files, not a bulk transport. That must be stated in the API docs, exactly as the upload path already states it.

Proposed API

Terminals on the client.file(path) request:

// Whole file, bytes
byte[] content = client.file("C:\\Windows\\Temp\\collect.bin").readBytes();

// Whole file, text with an explicit charset (no guessing, no default)
String text = client.file("C:\\inetpub\\logs\\u_ex260729.log")
    .readText(StandardCharsets.UTF_8);

// A range: from a position, for a length — the point of the exercise for big logs
byte[] tail = client.file("D:\\logs\\huge.log")
    .offset(1_073_741_824L)          // absolute byte position
    .length(65_536)                  // bytes to read; omit for "to the end"
    .readBytes();

// Streaming, so memory stays bounded by the buffer and not by the file
try (InputStream in = client.file("D:\\logs\\huge.log").openStream()) {
    ...
}
try (BufferedReader reader = client.file("D:\\logs\\huge.log")
        .charset(StandardCharsets.UTF_8)
        .openReader()) {
    reader.lines().filter(l -> l.contains("ERROR")).forEach(System.out::println);
}

// Integrity, reusing the digest probe ShellFileCopy already has
String sha256 = client.file("C:\\Windows\\Temp\\collect.ps1").digest("SHA256");

offset(long)/length(long) compose with every read terminal.

Negative offsets mean "from the end" — decided, and part of the scope:

// The tail case: last 8 KiB of a log, one seek, no scan
byte[] tail = client.file("D:\\logs\\huge.log").offset(-8192).readBytes();

Exact semantics to implement and document:

  • offset(-n) resolves server-side to max(0, size - n), so a file shorter than n returns the whole file, never an error and never a short read from a negative position.
  • length(...) still applies after the resolution and still clamps at EOF: offset(-8192).length(1024) returns the first 1 KiB of the last 8 KiB.
  • The size is read inside the same remote invocation as the seek (from the open FileStream), not by a separate info() round trip — otherwise a growing log would be tailed from a stale position.
  • offset(0) is the start of the file; there is no "negative zero" case to special-case.

Requirements

The remote read

  • Open with a share mode that tolerates other writers: [IO.File]::Open($p, 'Open', 'Read', 'ReadWrite'), not [IO.File]::OpenRead. A log being written by a running service is the single most common thing a caller wants to read, and OpenRead fails on it. Files held with a truly exclusive lock (pagefile.sys, live registry hives) still fail — map that to a clear exception naming the sharing violation.
  • Seek + bounded read, so a range costs a seek and not a full scan: position the stream at offset, read at most length bytes, base64 the buffer, write it out. Never read the whole file to serve a range.
  • Chunked, streaming output: the script writes base64 in fixed-size blocks so the client can decode incrementally through the streaming command terminal (Streaming APIs (phase 2): stream()/start() terminal methods on the fluent WinRMClient #111) instead of buffering the whole payload. Keep each write within the wire framing the shell already handles, and mind MaxEnvelopeSize (153 600) for the command itself, not just the output.
  • -EncodedCommand invocation and pure-ASCII output, exactly as in Remote file access (1/4): file properties and directory listing — client.file(path).info() and client.list(dir) with filters #145 — no cmd.exe quoting of caller-supplied paths, no dependence on the console code page.
  • certutil -encode fallback for whole-file reads when PowerShell is unavailable or constrained (strip the -----BEGIN CERTIFICATE----- framing it adds). Ranged reads have no certutil equivalent: fail with a message that says so.

Semantics to pin down and document

  • Ranges are byte ranges, not character ranges. A range boundary can split a multi-byte character, so readText(Charset) on a ranged read may yield U+FFFD at the edges. Say this in the Javadoc; do not try to be clever about it.
  • No BOM stripping in readBytes(). For readText/openReader, either strip a leading BOM or don't — pick one, document it, test it.
  • offset past EOF returns empty, not an error; length beyond EOF returns what exists. A file that grows or shrinks between two ranged reads is the caller's problem, but note it: this is not a snapshot.
  • Empty file, zero length, and 0-byte range are valid and return an empty result.
  • Size guard: an unbounded readBytes() on a multi-gigabyte file must not OOM the JVM silently. Enforce a documented default cap (e.g. 64 MiB) with an explicit maxBytes(long) override, and point callers at openStream()/downloadFile for anything larger.
  • Throughput expectation documented with a measured figure from the live host, next to the existing "designed for small script files, not bulk data" wording in file-transfers.md.
  • Timeout semantics follow the existing split: the blocking terminals use the wall-clock deadline; openStream()/openReader() use the inactivity semantics of the streaming terminals.

Tests & docs

  • FakeWsmanServer tests: whole-file read, ranged read (mid-file, at EOF, past EOF, zero length), negative offset (larger than, equal to and smaller than the file size, and combined with length), binary content with every byte value 0–255, a multi-byte character split across two chunks (must decode correctly through the incremental path), the size cap, and the sharing-violation and missing-file failures.
  • Digest agreement test: readBytes() of a file uploaded with uploadFile matches the local bytes, and digest("SHA256") matches the local digest.
  • WinRMLiveTest: read a real file whole, read a range from a large file (assert the range is served by a seek, i.e. duration does not scale with the file size), and read a file currently held open by another process.
  • files.md gains the read section; README gains the snippet; file-transfers.md cross-links the reverse direction.

Acceptance criteria

  • Whole-file and ranged reads return byte-exact content for binary and text files, verified by digest against the local original.
  • A range near the end of a large file is served without transferring the whole file, both with an absolute offset and with offset(-n).
  • A log file open for writing by another process can be read.
  • The default size cap prevents an accidental unbounded read, and openStream() reads a file larger than the cap with bounded memory.
  • mvn verify site green: no checkstyle/PMD/SpotBugs findings, full Javadoc, docs updated.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    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