Skip to content

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

Description

@bertysentry

First issue of a remote file access family: metadata and listing here, content reading in #146, downloadFile in #147, CLI subcommands in #148. This one introduces the shared foundation (the client.file(...) entry point, the RemoteFileInfo type, and the base64-framed PowerShell helper) that the other three build on.

Context

WinRM has no file-access verb of its own — nothing like SFTP's OPENDIR/READ. Reading files on a remote host over WinRM therefore means reusing one of the two channels the client already speaks:

  1. WMI/WQLCIM_DataFile and CIM_Directory expose real metadata (FileSize, LastModified, CreationDate, Readable, Hidden, System, Archive, Extension), and the client's WQL layer already 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 (it walks the filesystem inside WMI, and an unindexed CIM_DataFile query on a large tree can take minutes), recursion means one association query per directory, timestamps come back as CIM DMTF datetimes, and it cannot read content at all.

  2. The command shell — the channel ShellFileCopy already uses to upload files (chunked base64 through cmd.exe, decoded with certutil, digest-verified). Listing and reading are the same trick in reverse, and it is fast, recursive, and filters server-side.

This family takes the shell path and keeps WMI as a documented alternative for metadata-only cases. That keeps the zero-dependency promise, needs no extra port, and reuses the transport hardening (quota retries, envelope chunking, incremental decoding) already in the codebase.

Proposed API

A per-path entry point, sibling of wql(...) / command(...):

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

    // One path: existence and properties
    if (client.file("C:\\Windows\\Temp\\collect.log").exists()) {
        RemoteFileInfo info = client.file("C:\\Windows\\Temp\\collect.log").info().orElseThrow();
        System.out.println(info.size() + " bytes, modified " + info.lastModified());
    }

    // A directory: listing with filters
    List<RemoteFileInfo> logs = client.list("C:\\inetpub\\logs")
        .glob("*.log")                                  // server-side -Filter
        .recursive()                                    // off by default
        .maxDepth(3)
        .filesOnly()                                    // or directoriesOnly()
        .modifiedAfter(Instant.now().minus(Duration.ofDays(1)))
        .minSize(1024L)
        .execute();

    // Large trees stream, like WQL rows
    try (Stream<RemoteFileInfo> tree = client.list("D:\\data").recursive().stream()) {
        tree.filter(RemoteFileInfo::isDirectory).forEach(System.out::println);
    }
}

RemoteFileInfo — an 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
boolean isHidden(), isSystem(), isReadOnly(), isArchive(), isReparsePoint() Attributes bit flags
int attributes() raw FileAttributes value, for anything not exposed above

Requirements

The remote helper — base64-framed PowerShell, not dir

  • Never parse dir output: its column layout, date format and decimal separator are locale-dependent, and it has no timestamps beyond minute granularity. Use PowerShell (Get-ChildItem / [IO.FileInfo]).
  • Invoke it with powershell -NoProfile -NonInteractive -EncodedCommand <base64 UTF-16LE>. This sidesteps cmd.exe quoting entirely (no ^/"/% escaping games with user-supplied paths) and is immune to the console code page problems fixed in Command output is mis-decoded on non-English Windows: shell pinned to CP437, output decoded as the ANSI code page #142. Respect the existing MAX_COMMAND_LENGTH (8000) budget from ShellFileCopy: the scripts must stay terse; if one grows past the budget, upload it as a script file with the existing ShellFileCopy path and invoke it by name.
  • The script emits pure ASCII: it builds its records as UTF-8 bytes and writes them back as base64, so non-ASCII file names survive whatever the remote console encoding is. The client decodes base64 → UTF-8 → records. No charset guessing anywhere in this path.
  • Locale-independent field encoding inside the records: timestamps as .Ticks (UTC), sizes and attributes as plain integers, one record per line, tab-delimited with the path last. Windows forbids control characters (0x00–0x1F) in file names, so tab and newline are safe delimiters — state that reasoning in the code comment.
  • PowerShell may be unavailable or constrained (AppLocker, Constrained Language Mode, PowerShell removed). Detect the failure and report it as a clear WinRMClientException naming the cause, instead of surfacing a raw cmd.exe error. Document the WMI/ASSOCIATORS OF alternative in that case.

Behavior

  • Access-denied entries must not be silently dropped: Get-ChildItem on a tree with protected subdirectories partially fails. Collect those paths and expose them (e.g. RemoteFileList.inaccessible() from execute(), and a onInaccessible(Consumer<String>) hook for stream()) so a caller can tell "no matches" from "could not look".
  • Do not follow reparse points/junctions/symlinks when recursing (cycle risk, and C:\Documents and Settings-style legacy junctions loop straight back). Expose them as entries with isReparsePoint() true.
  • Filters are pushed server-side where PowerShell supports it cheaply (-Filter for the glob, -Recurse, -Depth, -File/-Directory); size and time predicates are applied by the script (Where-Object) so a big tree is not shipped over the wire just to be discarded locally. Document which filter is evaluated where.
  • glob(String) is a Windows wildcard pattern (*, ?), not a regex and not a full path — document that -Filter uses the legacy 8.3-aware matcher, so *.log also matches x.log1-style short names; 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().
  • info() returns Optional.empty() for a missing path — a non-existent file is not an error. Genuine failures (access denied on the path itself, invalid path, unreachable) throw.
  • UNC paths (\\server\share\...) work when the caller's credentials can reach them, i.e. 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 the \\?\-aware .NET APIs and cover one such path in the live test.

Tests & docs

  • FakeWsmanServer-based tests: scripted base64 record payloads → parsed RemoteFileInfo values, including a non-ASCII name, a >4 GB size, a hidden+system entry, a reparse point, an empty directory, and a partially-inaccessible tree.
  • Record-parser unit tests for malformed/truncated payloads (must fail with a clear exception, never with a half-populated object).
  • WinRMLiveTest coverage against a real host (anaxagore): list C:\Windows\Temp, a recursive listing with maxDepth, and a non-ASCII file name.
  • New src/site/markdown/files.md page (remote file access: listing, properties, the shell-vs-WMI trade-off, limitations) linked from index.md; README gets the one-liner + snippet.

Acceptance criteria

  • client.file(path).exists()/.info() and client.list(dir) 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 published.

🤖 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