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
{{ message }}
Repository navigation
Remote file access (2/4): file properties and directory listing — client.file(path).info() and client.file(dir).list() with filters #145
Second issue of the remote file access family, building on the client.file(path) entry point and the package-private RemoteFiles primitive (script → decoded InputStream, exit code → exception) introduced by #146. Download follows in #147, CLI subcommands in #148.
Context
WinRM has no file-access verb of its own. For metadata and listing there are two channels the client already speaks:
WMI/WQL — CIM_DataFile and CIM_Directory expose real metadata, and the WQL layer supports the association form needed to list a directory:
SELECT Name, FileSize, LastModified FROM ASSOCIATORS OF {Win32_Directory.Name="C:\\Windows\\Temp"} WHERE ResultClass = CIM_DataFile
Workable, but the WMI file provider is notoriously slow (an unindexed CIM_DataFile query on a large tree can take minutes), recursion means one association query per directory, and timestamps come back as CIM DMTF datetimes.
This issue takes the shell path and keeps WMI as a documented alternative for metadata-only cases (e.g. when PowerShell is constrained).
Proposed API
A single entry point on the client — client.file(path) from #146 — with listing as a terminal on it (no separate client.list(...), which keeps WinRMClient small and path handling in one place):
try (WinRMClientclient = WinRMClient.builder("server01.acme.com")
.credentials("ACME\\admin", password)
.build()) {
// One path: existence and propertiesOptional<RemoteFileInfo> info = client.file("C:\\Windows\\Temp\\collect.log").info();
info.ifPresent(i -> System.out.println(i.size() + " bytes, modified " + i.lastModified()));
booleanthere = client.file("C:\\Windows\\Temp\\collect.log").exists();
// A directory: listing with filtersRemoteFileListlogs = client.file("C:\\inetpub\\logs").list()
.glob("*.log")
.recursive() // off by default
.maxDepth(3)
.filesOnly() // or directoriesOnly()
.modifiedAfter(Instant.now().minus(Duration.ofDays(1)))
.minSize(1024L)
.execute();
logs.entries(); // List<RemoteFileInfo>logs.inaccessible(); // List<String> — paths that could not be read// Large trees stream, like WQL rowstry (Stream<RemoteFileInfo> tree = client.file("D:\\data").list()
.recursive()
.onInaccessible(path -> System.err.println("skipped " + path))
.stream()) {
tree.filter(RemoteFileInfo::isDirectory).forEach(System.out::println);
}
}
Types (flat in org.metricshub.winrm, next to WqlRequest/CommandRequest):
RemoteDirectoryListing — the request returned by list(): filters + execute()/stream().
RemoteFileList — execute()'s result: entries() + inaccessible(). One reporting mechanism: onInaccessible(Consumer<String>) is the hook for both terminals; execute() simply fills inaccessible() through it.
RemoteFileInfo — immutable value type in the style of WqlRow (Java 11, so a final class with accessors, not a record):
raw FileAttributes value, for anything not exposed above
Requirements
The listing script — a .NET walker, not Get-ChildItem, never dir
Never parse dir output: locale-dependent columns, dates and decimal separator, minute granularity.
Do not use Get-ChildItem either: -Depth needs PowerShell 5 and -File/-Directory PowerShell 3, and its object pipeline is slow on big trees. Walk with [IO.DirectoryInfo].EnumerateFileSystemInfos() and an explicit stack (works on PowerShell 2 / .NET 4, i.e. 2008 R2). Each behavior below is then a couple of lines inside the loop:
maxDepth — the stack carries the depth;
reparse points/junctions/symlinks are emitted but never descended into (cycle risk: C:\Documents and Settings-style legacy junctions loop straight back), with isReparsePoint() true;
access-denied directories are caught per directory and emitted as ! records, the walk continues;
glob, filesOnly/directoriesOnly, size and time predicates are all evaluated server-side, so a big tree is not shipped over the wire just to be discarded locally.
Numbers are plain integers — already locale-proof, no encoding needed. Only the path (and the error message) is base64'd UTF-8, so non-ASCII names survive whatever the remote console code page is. No charset guessing anywhere.
Timestamps as FileTime (100 ns since 1601-01-01 UTC) → Instant with one epoch offset.
Parsing is a split(" ", 7) plus a field-count check: a truncated or malformed record fails with a clear exception naming the line, never a half-populated object.
For list() the script output is not wrapped in base64 as a whole (unlike reads): the RemoteFiles primitive exposes the raw stdout lines for this case.
Behavior
info() returns Optional.empty() for a missing path (exit code 2 from the primitive) — a non-existent file is not an error. Genuine failures (access denied on the path itself, invalid path, unreachable) throw. exists() = info().isPresent().
glob(String) is a Windows wildcard pattern (*, ?) matched against the entry name, not a regex and not a full path. With recursion, directories are always traversed; the glob filters what is emitted. Document the matching rules; offer regex(String) only if a follow-up needs it.
Long paths (>260 chars) must not silently truncate: use \\?\-prefixed paths with the .NET APIs and cover one such path in the live test.
Tests & docs
Record-parser unit tests: every field, a non-ASCII name, a >4 GB size, a hidden+system entry, a reparse point, ! records, and malformed/truncated lines (clear exception).
FakeWsmanServer-based tests: scripted record payloads → RemoteFileList / streamed RemoteFileInfo, including an empty directory, a partially-inaccessible tree (entries andinaccessible()), and info() on a missing path → Optional.empty().
WinRMLiveTest coverage against a real host (anaxagore): list C:\Windows\Temp, a recursive listing with maxDepth, a non-ASCII file name, a long path, and a junction loop that terminates.
changed the title [-]Remote file access (1/4): file properties and directory listing — client.file(path).info() and client.list(dir) with filters[/-][+]Remote file access (2/4): file properties and directory listing — client.file(path).info() and client.file(dir).list() with filters[/+]on Sep 23, 2026
Context
WinRM has no file-access verb of its own. For metadata and listing there are two channels the client already speaks:
WMI/WQL —
CIM_DataFileandCIM_Directoryexpose real metadata, and the WQL layer supports the association form needed to list a directory:Workable, but the WMI file provider is notoriously slow (an unindexed
CIM_DataFilequery on a large tree can take minutes), recursion means one association query per directory, and timestamps come back as CIMDMTFdatetimes.The command shell, through the
RemoteFilesprimitive of Remote file access (1/4): foundation + read remote file content — client.file(path), RemoteFiles primitive, whole file, byte ranges, text, streaming #146 — fast, recursive, filters server-side.This issue takes the shell path and keeps WMI as a documented alternative for metadata-only cases (e.g. when PowerShell is constrained).
Proposed API
A single entry point on the client —
client.file(path)from #146 — with listing as a terminal on it (no separateclient.list(...), which keepsWinRMClientsmall and path handling in one place):Types (flat in
org.metricshub.winrm, next toWqlRequest/CommandRequest):RemoteDirectoryListing— the request returned bylist(): filters +execute()/stream().RemoteFileList—execute()'s result:entries()+inaccessible(). One reporting mechanism:onInaccessible(Consumer<String>)is the hook for both terminals;execute()simply fillsinaccessible()through it.RemoteFileInfo— immutable value type in the style ofWqlRow(Java 11, so a final class with accessors, not a record):String path()String name()boolean isDirectory()Attributeslong size()Length(0 for directories)Instant lastModified(),created(),lastAccessed()*TimeUtc, as FileTimeboolean isHidden(),isSystem(),isReadOnly(),isArchive(),isReparsePoint()Attributesbit flagsint attributes()FileAttributesvalue, for anything not exposed aboveRequirements
The listing script — a .NET walker, not
Get-ChildItem, neverdirdiroutput: locale-dependent columns, dates and decimal separator, minute granularity.Get-ChildItemeither:-Depthneeds PowerShell 5 and-File/-DirectoryPowerShell 3, and its object pipeline is slow on big trees. Walk with[IO.DirectoryInfo].EnumerateFileSystemInfos()and an explicit stack (works on PowerShell 2 / .NET 4, i.e. 2008 R2). Each behavior below is then a couple of lines inside the loop:maxDepth— the stack carries the depth;C:\Documents and Settings-style legacy junctions loop straight back), withisReparsePoint()true;!records, the walk continues;filesOnly/directoriesOnly, size and time predicates are all evaluated server-side, so a big tree is not shipped over the wire just to be discarded locally.RemoteFilesprimitive of Remote file access (1/4): foundation + read remote file content — client.file(path), RemoteFiles primitive, whole file, byte ranges, text, streaming #146: same-EncodedCommandinvocation, same Constrained Language Mode check and exit-code mapping, same automatic.ps1transfer if the script outgrows the command line.Record format — one ASCII line per entry, only the path encoded
Instantwith one epoch offset.split(" ", 7)plus a field-count check: a truncated or malformed record fails with a clear exception naming the line, never a half-populated object.list()the script output is not wrapped in base64 as a whole (unlike reads): theRemoteFilesprimitive exposes the raw stdout lines for this case.Behavior
info()returnsOptional.empty()for a missing path (exit code 2 from the primitive) — a non-existent file is not an error. Genuine failures (access denied on the path itself, invalid path, unreachable) throw.exists()=info().isPresent().glob(String)is a Windows wildcard pattern (*,?) matched against the entry name, not a regex and not a full path. With recursion, directories are always traversed; the glob filters what is emitted. Document the matching rules; offerregex(String)only if a follow-up needs it.stream()builds on the streaming command terminal of Streaming APIs (phase 2): stream()/start() terminal methods on the fluent WinRMClient #111: records are parsed and yielded as the output chunks arrive, memory bounded by the parse buffer, not by the tree size. Must be closed (try-with-resources) likeWqlRequest.stream().\\server\share\...) work only with credential delegation (Kerberos credential delegation: allowDelegation() and CLI --allow-delegate (winrs -allowdelegate) #141) — document the second-hop limitation rather than pretending it works.\\?\-prefixed paths with the .NET APIs and cover one such path in the live test.Tests & docs
!records, and malformed/truncated lines (clear exception).FakeWsmanServer-based tests: scripted record payloads →RemoteFileList/ streamedRemoteFileInfo, including an empty directory, a partially-inaccessible tree (entries andinaccessible()), andinfo()on a missing path →Optional.empty().WinRMLiveTestcoverage against a real host (anaxagore): listC:\Windows\Temp, a recursive listing withmaxDepth, a non-ASCII file name, a long path, and a junction loop that terminates.files.md(created in Remote file access (1/4): foundation + read remote file content — client.file(path), RemoteFiles primitive, whole file, byte ranges, text, streaming #146) gains the listing/properties section, the shell-vs-WMI trade-off and the limitations; README gets the one-liner + snippet.Acceptance criteria
client.file(path).exists()/.info()andclient.file(dir).list()with all documented filters work against a real host and againstFakeWsmanServer.mvn verify sitegreen: no checkstyle/PMD/SpotBugs findings, full Javadoc,files.mdupdated.🤖 Generated with Claude Code