You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
it is ASCII-only, so the client never has to know or guess the remote console encoding to recover the exact bytes.
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, bytesbyte[] content = client.file("C:\\Windows\\Temp\\collect.bin").readBytes();
// Whole file, text with an explicit charset (no guessing, no default)Stringtext = 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 logsbyte[] 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 filetry (InputStreamin = client.file("D:\\logs\\huge.log").openStream()) {
...
}
try (BufferedReaderreader = 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 hasStringsha256 = 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 scanbyte[] 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.
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.
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:offset(long)/length(long)compose with every read terminal.Negative offsets mean "from the end" — decided, and part of the scope:
Exact semantics to implement and document:
offset(-n)resolves server-side tomax(0, size - n), so a file shorter thannreturns 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.FileStream), not by a separateinfo()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
[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, andOpenReadfails 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.offset, read at mostlengthbytes, base64 the buffer, write it out. Never read the whole file to serve a range.MaxEnvelopeSize(153 600) for the command itself, not just the output.-EncodedCommandinvocation 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 — nocmd.exequoting of caller-supplied paths, no dependence on the console code page.certutil -encodefallback for whole-file reads when PowerShell is unavailable or constrained (strip the-----BEGIN CERTIFICATE-----framing it adds). Ranged reads have nocertutilequivalent: fail with a message that says so.Semantics to pin down and document
readText(Charset)on a ranged read may yieldU+FFFDat the edges. Say this in the Javadoc; do not try to be clever about it.readBytes(). ForreadText/openReader, either strip a leading BOM or don't — pick one, document it, test it.offsetpast EOF returns empty, not an error;lengthbeyond 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.length, and 0-byte range are valid and return an empty result.readBytes()on a multi-gigabyte file must not OOM the JVM silently. Enforce a documented default cap (e.g. 64 MiB) with an explicitmaxBytes(long)override, and point callers atopenStream()/downloadFilefor anything larger.file-transfers.md.openStream()/openReader()use the inactivity semantics of the streaming terminals.Tests & docs
FakeWsmanServertests: 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 withlength), 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.readBytes()of a file uploaded withuploadFilematches the local bytes, anddigest("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.mdgains the read section; README gains the snippet;file-transfers.mdcross-links the reverse direction.Acceptance criteria
offset(-n).openStream()reads a file larger than the cap with bounded memory.mvn verify sitegreen: no checkstyle/PMD/SpotBugs findings, full Javadoc, docs updated.🤖 Generated with Claude Code