Skip to content

Remote file access (2/4): file properties and directory listing — client.file(path).info() and client.file(dir).list() with filters #145

Description

@bertysentry

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:

  1. 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.

  2. The command shell, through the RemoteFiles primitive 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 separate client.list(...), which keeps WinRMClient small and path handling in one place):

try (WinRMClient client = WinRMClient.builder("server01.acme.com")
        .credentials("ACME\\admin", password)
        .build()) {

    // One path: existence and properties
    Optional<RemoteFileInfo> info = client.file("C:\\Windows\\Temp\\collect.log").info();
    info.ifPresent(i -> System.out.println(i.size() + " bytes, modified " + i.lastModified()));
    boolean there = client.file("C:\\Windows\\Temp\\collect.log").exists();

    // A directory: listing with filters
    RemoteFileList logs = 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 rows
    try (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):
accessor source
String path() full path as reported by the host
String name() last path element
boolean isDirectory() Attributes
long size() Length (0 for directories)
Instant lastModified(), created(), lastAccessed() *TimeUtc, as FileTime
boolean isHidden(), isSystem(), isReadOnly(), isArchive(), isReparsePoint() Attributes bit flags
int attributes() 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.
  • Runs through the RemoteFiles primitive of Remote file access (1/4): foundation + read remote file content — client.file(path), RemoteFiles primitive, whole file, byte ranges, text, streaming #146: same -EncodedCommand invocation, same Constrained Language Mode check and exit-code mapping, same automatic .ps1 transfer if the script outgrows the command line.

Record format — one ASCII line per entry, only the path encoded

F <attributes> <size> <lastWriteFileTimeUtc> <creationFileTimeUtc> <lastAccessFileTimeUtc> <base64(UTF-8 path)>
! <base64(UTF-8 path)> <base64(UTF-8 message)>
  • 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.
  • 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) like WqlRequest.stream().
  • UNC paths (\\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.
  • 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 and inaccessible()), 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.
  • 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() and client.file(dir).list() with all documented filters work against a real host and against FakeWsmanServer.
  • Non-ASCII file names round-trip correctly regardless of the remote machine's code page.
  • A recursive listing of a tree containing an access-denied subdirectory returns the readable entries and reports the inaccessible paths.
  • Recursion terminates on a junction loop.
  • mvn verify site green: no checkstyle/PMD/SpotBugs findings, full Javadoc, files.md updated.

🤖 Generated with Claude Code

Activity

  1. 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
  2. self-assigned this
    on Sep 24, 2026
  3. added a commit that references this issue on Sep 24, 2026
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