Skip to content

Fix Windows path detection in cleanPathWithBase - #644

Open
owenthereal wants to merge 1 commit into
pkg:masterfrom
owenthereal:master
Open

Fix Windows path detection in cleanPathWithBase#644
owenthereal wants to merge 1 commit into
pkg:masterfrom
owenthereal:master

Conversation

@owenthereal

Copy link
Copy Markdown

Summary

Fix cleanPathWithBase to use filepath.IsAbs on the original path before converting to forward slashes. This correctly handles Windows paths like C:\foo which filepath.IsAbs recognizes but path.IsAbs (after ToSlash conversion to C:/foo) does not.

Problem

When using WithStartDirectory on Windows, absolute paths like C:\Users\foo are incorrectly treated as relative paths because:

  1. filepath.ToSlash(filepath.Clean("C:\\Users\\foo"))C:/Users/foo
  2. path.IsAbs("C:/Users/foo")false (only checks for / prefix)
  3. Result: path gets joined with start directory, causing doubled paths

Solution

Check filepath.IsAbs(p) on the original path before the ToSlash conversion. filepath.IsAbs is platform-aware and correctly identifies Windows absolute paths.

Related

This fix resolves Windows SFTP path handling issues in owenthereal/upterm#440

Use filepath.IsAbs on the original path before converting to forward
slashes. This correctly handles Windows paths like "C:\foo" which
filepath.IsAbs recognizes but path.IsAbs (after ToSlash conversion
to "C:/foo") does not.
@puellanivis

Copy link
Copy Markdown
Collaborator

Remote paths to windows servers require absolute paths to be encoded as /C:/path/to/file.

This is not a bug.

@puellanivis puellanivis reopened this Jan 13, 2026
@puellanivis

puellanivis commented Jan 13, 2026

Copy link
Copy Markdown
Collaborator

I keep thinking I’m missing something here.

I’m thinking I need this explained better. This code is only called from within the RequestServer, and only on paths coming from a remote client. Where, like I said, any absolute path coming from a client to a windows server must be encoded as /c:/path/to/file, but I can’t shake the feeling that I’m missing something that I should be able to see…

@owenthereal

Copy link
Copy Markdown
Author

The premise that "this code is only called from within the RequestServer, and only on paths coming from a remote client" isn't quite right. cleanPath is reachable through public API surface that takes Go-side strings:

  • NewRequest(method, path) (request.go:160) runs cleanPath(path) on whatever the caller passes.
  • WithStartDirectory(dir) (request-server.go:55) runs cleanPath(dir) on a Go-side string.

On Windows, a Go program calling either of these with a native path — e.g. os.UserHomeDir() returning C:\Users\foo, or any path produced by filepath.Join — sends an absolute Windows path through. With the original code on Windows:

cleanPathWithBase("/C:/Users/foo", "C:\\Users\\foo\\test.txt")
  = path.Join("/C:/Users/foo", "C:/Users/foo/test.txt")
  = "/C:/Users/foo/C:/Users/foo/test.txt"   ← doubled

That happens because path.IsAbs("C:/Users/foo/test.txt") is false (it only checks for a leading /), so the path goes through path.Join instead of being returned as-is. filepath.IsAbs on the original (pre-ToSlash) string is platform-aware and recognizes the Windows absolute path correctly, which is what this change uses.

Agreed that the wire form for SFTP-to-Windows is /C:/path, and that conformant clients send canonical paths. But (a) the public Go-side API takes native paths in practice, and (b) being lenient on input here is harmless — on Unix, filepath.IsAbs and path.IsAbs agree on these inputs, so non-Windows behavior is unchanged.

Happy to add a Windows test that exercises this if that would help.

@puellanivis

Copy link
Copy Markdown
Collaborator

Yeah, a Windows-specific test would be great.

🤔 It looks like you’re right, there are a few API positions where we’re calling this on a string that users might naturally expect to use a local filepath, rather than a POSIX path. It might be a good idea to instead split these uses into separate cleanRemotePath and cleanLocalPath… if we don’t leave any plain cleanPath around, then we can’t accidentally leave a path undecided as to if it is expected to be remote or local.

optimalone added a commit to owenthereal/upterm that referenced this pull request Sep 9, 2026
upterm carried two go.mod replace directives, and together they make
`go install github.com/owenthereal/upterm/cmd/upterm@latest` fail: Go
builds ...@latest in module-aware mode and refuses a main module whose
go.mod contains replace directives (https://go.dev/ref/mod#go-install).
This is issue #511. This removes the sftp one; the x/crypto replace
remains and still blocks `go install` on its own.

The fork existed to carry pkg/sftp#644, which changes cleanPathWithBase
to test filepath.IsAbs on the original path instead of path.IsAbs on the
slash-converted one. That patch is wrong, and the bug it works around is
ours.

cleanPathWithBase operates on SFTP wire paths, which are POSIX on every
platform: a Windows server exposes C:\dir to clients as /C:/dir.
path.IsAbs is not failing to be cross-platform there, it implements the
POSIX rule everywhere, which is what a wire path needs. filepath.IsAbs is
platform-aware, and on Windows requires a volume name, so it reports
false for every path without a drive letter. Substituting it lets the
server's own filesystem conventions decide how remote paths are read.
Running Go's volumeNameLen and IsAbs from
internal/filepathlite/path_windows.go against both versions:

  base            p                       upstream              with #644
  /               C:\a                    /C:/a                 C:/a
  /C:/Users/foo   /C:/Users/foo/test.txt  /C:/Users/foo/test.txt
                                                                /C:/Users/foo/C:/Users/foo/test.txt
  /start          /abs/path               /abs/path             /start/abs/path

The first breaks pkg/sftp's own TestCleanPath. The second is the doubling
#644 sets out to fix, reintroduced for the canonical wire form. The third
re-roots every absolute remote path under the session's start directory,
which is exactly how upterm runs its SFTP server.

The real trigger was on the client side. Client.Open sends the path
verbatim -- client.go has no filepath reference at all -- and these tests
passed filepath.Join(t.TempDir(), ...), so on Windows the wire carried a
native C:\... path rather than the /C:/... form OpenSSH's clients use.
The server then joined it onto the start directory and the request landed
on /C:/Users/runneradmin/C:/Users/runneradmin/.../download-test.txt.

Tests now encode paths the way the protocol expects, so the fork is no
longer needed and upterm requires github.com/pkg/sftp directly. The two
are the same code: v1.13.10 is commit 939b203, and the fork's base is two
commits ahead of it, both an x/crypto bump.

The functional SFTP tests cover this end to end and run unskipped on the
Windows job, but only implicitly; a regression would surface as a
confusing permission or not-found error from a doubled path, layers away
from the cause. TestRemotePathIsPOSIXAbsolute states the requirement
instead, which is platform-independent even though the bug is not:
whatever is handed to the sftp client must satisfy path.IsAbs.

Not verified on Windows locally. The table above is Go's Windows path
logic executed on darwin, not a Windows test run. The Test (Windows) job
covers ftests, so CI is the check that matters.
optimalone added a commit to owenthereal/upterm that referenced this pull request Sep 9, 2026
upterm carried two go.mod replace directives, and together they make
`go install github.com/owenthereal/upterm/cmd/upterm@latest` fail: Go
builds ...@latest in module-aware mode and refuses a main module whose
go.mod contains replace directives (https://go.dev/ref/mod#go-install).
This is issue #511. This removes the sftp one; the x/crypto replace
remains and still blocks `go install` on its own.

The fork existed to carry pkg/sftp#644, which changes cleanPathWithBase
to test filepath.IsAbs on the original path instead of path.IsAbs on the
slash-converted one. That patch is wrong, and the bug it works around is
ours.

cleanPathWithBase operates on SFTP wire paths, which are POSIX on every
platform: a Windows server exposes C:\dir to clients as /C:/dir.
path.IsAbs is not failing to be cross-platform there, it implements the
POSIX rule everywhere, which is what a wire path needs. filepath.IsAbs is
platform-aware, and on Windows requires a volume name, so it reports
false for every path without a drive letter. Substituting it lets the
server's own filesystem conventions decide how remote paths are read.
Running Go's volumeNameLen and IsAbs from
internal/filepathlite/path_windows.go against both versions:

  base            p                       upstream              with #644
  /               C:\a                    /C:/a                 C:/a
  /C:/Users/foo   /C:/Users/foo/test.txt  /C:/Users/foo/test.txt
                                                                /C:/Users/foo/C:/Users/foo/test.txt
  /start          /abs/path               /abs/path             /start/abs/path

The first breaks pkg/sftp's own TestCleanPath. The second is the doubling
#644 sets out to fix, reintroduced for the canonical wire form. The third
re-roots every absolute remote path under the session's start directory,
which is exactly how upterm runs its SFTP server.

The real trigger was on the client side. Client.Open sends the path
verbatim -- client.go has no filepath reference at all -- and these tests
passed filepath.Join(t.TempDir(), ...), so on Windows the wire carried a
native C:\... path rather than the /C:/... form OpenSSH's clients use.
The server then joined it onto the start directory and the request landed
on /C:/Users/runneradmin/C:/Users/runneradmin/.../download-test.txt.

Tests now encode paths the way the protocol expects, so the fork is no
longer needed and upterm requires github.com/pkg/sftp directly. The two
are the same code: v1.13.10 is commit 939b203, and the fork's base is two
commits ahead of it, both an x/crypto bump.

The functional SFTP tests cover this end to end and run unskipped on the
Windows job, but only implicitly; a regression would surface as a
confusing permission or not-found error from a doubled path, layers away
from the cause. TestRemotePathIsPOSIXAbsolute states the requirement
instead, which is platform-independent even though the bug is not:
whatever is handed to the sftp client must satisfy path.IsAbs.

Not verified on Windows locally. The table above is Go's Windows path
logic executed on darwin, not a Windows test run. The Test (Windows) job
covers ftests, so CI is the check that matters.
owenthereal pushed a commit to owenthereal/upterm that referenced this pull request Sep 9, 2026
…tion tests, exit-status and charm.land/ssh fixes (#523)

* Add channel-proxy characterization tests, and a trustworthy baseline

The functional suite is the safety net for replacing the relay's front
door with a stock x/crypto proxy. Today the relay forwards decrypted SSH
*packets*, so channel numbering, windows, request ordering and close
ordering survive untouched. A stock proxy terminates the channel layer on
each side and re-originates every channel and every request, at which
point each of those becomes a way to lose data silently.

ftests/proxy_test.go pins eight of them across ssh/ws and single/multi
node: exit-status delivered before the channel closes, a forced command's
termination reported at all, five megabytes each way through sftp,
mid-session window-change, channel requests sent before the shell starts,
an unknown request refused rather than fatal, eight concurrent guests,
and a session idle across several keepalive rounds.

Three hazards are deliberately absent, with reasons recorded in the file,
because upterm's guest surface cannot reach them and a test that cannot
fail is worse than no test: stderr as a separate stream, stdin half-close,
and a guest whose key the host rejects. They belong to the forwarder's own
unit tests.

testHostClientCallback also gets a trustworthy wait. It failed roughly one
full-suite run in five, which is corrosive to a rewrite that uses the
suite as its safety net. Measured, the client-left callback fires ~600us
after Client.Close() returns, so the 2s budget it used had three orders of
magnitude of headroom and the failure was not a slow machine. The test was
using wall-clock time as a proxy for causality on an event it had already
caused. It now allows 10s, dumps goroutine stacks on timeout so a
recurrence is diagnosable, and buffers the event channels: the emitter
delivers from a goroutine that holds an emitter-wide lock while it blocks
on the listener, so one callback stuck on an unbuffered send stalls every
later event on that host.

* Report a forced command's exit code to the guest reliably

HandleSession read the session's exit status off run.Group.Run, which
returns whichever actor finished first. When a forced command exits, two
actors unblock at the same instant: the wait, which carries the status,
and the output copy, which sees the pty's EOF and returns nil. The guest
therefore got the command's real code or a clean 0 depending on
scheduling. Measured with --force-command 'exit 42': 42 in one of the
four functional-test topologies and 0 in the other three, same run.

The actor that waits on the command now records the status itself.
run.Group.Run drains every actor before returning, so reading it
afterwards is ordered.

Windows never reported the code at all. pty.Wait returned
fmt.Errorf("exit status %d"), which no caller could parse, so every
failing forced command surfaced as 1. It now returns a typed *ExitError.

A status is used only when the process exited under its own control.
exec reports -1 for a signalled process, which is what happens when the
pty is closed underneath a still-running command during teardown, and an
SSH exit status is marshalled as a uint32, so passing that on would send
the guest 4294967295.

Also stop falling through after rejecting a session with no pty. Exit
closes the channel, so the rest of the handler was attaching a dead
session to the shared output writer, starting a keepalive ticker and a
window-change loop against it, and ending with a second Exit that could
only fail.

* Move to charm.land/ssh v0.4.3

The library's import path moved to charm.land/ssh, a vanity path on the
same GitHub repository. It is not archived and has not been absorbed into
charmbracelet/wish, which is built on top of it.

upterm was pinned to v0.0.0-20250826160808-ebfa259c7309, a pseudo-version
from 2025-08-26, so the tree was a year behind on the library that
terminates SSH for every host, missing two merged data-race fixes
(SetOption, handshakeDeadline). Catching up needs no API changes: a path
swap in five files and go.mod.

It does not fix the sess.pty race, which is still present in v0.4.3 and is
reported upstream as charmbracelet/ssh#58 with a fix in #59.
testProxyWindowChange works around it in the meantime by ordering the two
accesses.

* Drop the sftp replace directive, and send canonical SFTP paths

upterm carried two go.mod replace directives, and together they make
`go install github.com/owenthereal/upterm/cmd/upterm@latest` fail: Go
builds ...@latest in module-aware mode and refuses a main module whose
go.mod contains replace directives (https://go.dev/ref/mod#go-install).
This is issue #511. This removes the sftp one; the x/crypto replace
remains and still blocks `go install` on its own.

The fork existed to carry pkg/sftp#644, which changes cleanPathWithBase
to test filepath.IsAbs on the original path instead of path.IsAbs on the
slash-converted one. That patch is wrong, and the bug it works around is
ours.

cleanPathWithBase operates on SFTP wire paths, which are POSIX on every
platform: a Windows server exposes C:\dir to clients as /C:/dir.
path.IsAbs is not failing to be cross-platform there, it implements the
POSIX rule everywhere, which is what a wire path needs. filepath.IsAbs is
platform-aware, and on Windows requires a volume name, so it reports
false for every path without a drive letter. Substituting it lets the
server's own filesystem conventions decide how remote paths are read.
Running Go's volumeNameLen and IsAbs from
internal/filepathlite/path_windows.go against both versions:

  base            p                       upstream              with #644
  /               C:\a                    /C:/a                 C:/a
  /C:/Users/foo   /C:/Users/foo/test.txt  /C:/Users/foo/test.txt
                                                                /C:/Users/foo/C:/Users/foo/test.txt
  /start          /abs/path               /abs/path             /start/abs/path

The first breaks pkg/sftp's own TestCleanPath. The second is the doubling
#644 sets out to fix, reintroduced for the canonical wire form. The third
re-roots every absolute remote path under the session's start directory,
which is exactly how upterm runs its SFTP server.

The real trigger was on the client side. Client.Open sends the path
verbatim -- client.go has no filepath reference at all -- and these tests
passed filepath.Join(t.TempDir(), ...), so on Windows the wire carried a
native C:\... path rather than the /C:/... form OpenSSH's clients use.
The server then joined it onto the start directory and the request landed
on /C:/Users/runneradmin/C:/Users/runneradmin/.../download-test.txt.

Tests now encode paths the way the protocol expects, so the fork is no
longer needed and upterm requires github.com/pkg/sftp directly. The two
are the same code: v1.13.10 is commit 939b203, and the fork's base is two
commits ahead of it, both an x/crypto bump.

The functional SFTP tests cover this end to end and run unskipped on the
Windows job, but only implicitly; a regression would surface as a
confusing permission or not-found error from a doubled path, layers away
from the cause. TestRemotePathIsPOSIXAbsolute states the requirement
instead, which is platform-independent even though the bug is not:
whatever is handed to the sftp client must satisfy path.IsAbs.

Not verified on Windows locally. The table above is Go's Windows path
logic executed on darwin, not a Windows test run. The Test (Windows) job
covers ftests, so CI is the check that matters.

* Stop one guest from wedging or killing the whole session

The relay's functional suite has an intermittent failure where the host's
client-left callback never fires. It was recorded as a timing flake; it is
not. Two defects in MultiWriter, the fan-out that feeds every attached
guest and the host's own terminal, both reachable in production.

Write held writeMu across the writes. A guest that stops reading its SSH
channel blocks there once the window fills, so Append and Remove waited on
it. HandleSession removes its writer as it returns and emits the
client-left event behind that, so the event was never sent. Write now
snapshots the writers under the lock and writes with it released.

Write also returned the first writer's error, which aborted the io.Copy
feeding it from the pty. That ended the command and tore down the host, so
a guest losing its connection at the wrong moment took the whole session
with it, and the goroutine dump from the Windows job shows exactly that:
the host gone while the test still waited. Errors also abandoned the rest
of the slice, so a broken guest silenced everyone attached after it. A
failing writer is now dropped and the fan-out continues; the producer
always sees success.

Remove counted down to index 1, so whichever writer sat at index 0 could
never be removed. Latent, because index 0 is the host's own stdout.

Two things found alongside, in the same I/O plumbing:

contextReader handed the caller's buffer to a goroutine that outlives an
abandoned Read, so a read completing after its Read call had returned
wrote into a buffer io.Copy had already reused. It reads into its own
buffer now and copies out only on the path that is still listening. It
also closed the result channel on the way out, so a Read racing
cancellation could return (0, nil), which io.Copy treats as neither data
nor EOF and spins on. Its test asserted the abandoned read never runs,
which is not true and cannot be: the read is unstoppable, only unheard.
Calling t.Error from it panicked the binary once the subtest had finished,
which is why ok  	github.com/owenthereal/upterm/io	90.473s was already red on master.

testClientCallbacks never drained the guest's output, which is what
manufactured the back-pressure that exposed all of this.

Not fixed here: the fan-out is still serial, so a guest that has stopped
reading holds everyone up until its write returns. That needs per-writer
buffering and is tracked separately.

* Accept native Windows paths from SFTP clients, and drop dead tilde handling

Dropping the sftp fork restored the protocol's path handling, which cost
something: the fork accepted native Windows paths, and upstream does not.

SFTP paths are POSIX on every platform, so an absolute path on a Windows
host travels as /C:/dir/file. A guest looking at a Windows machine
reasonably types C:\dir\file instead, which the protocol reads as
relative, so the server resolves it against the session's start directory
and the request arrives as /C:/Users/me/C:/dir/file. Before this the file
transfer failed with "The filename, directory name, or volume label syntax
is incorrect", naming a path the guest never typed and with no hint that
the fix is to spell it /C:/... instead.

Windows filenames cannot contain ':', so a drive letter anywhere but the
front can only have arrived that way, and the last one is the path the
guest meant. That makes the repair exact rather than a guess. It is
applied on Windows only: ':' is legal in a POSIX filename, so on a Linux
host a guest asking for C:\foo really is naming a file under the start
directory, and repairing it there would break a correct case.

Tilde handling is removed rather than fixed. Tilde expansion is not part
of SFTP, and the branch could never run: the library resolves every
request against the start directory before a handler sees it, so the path
is always absolute by then and never still begins with "~". Its tests
passed by calling resolvePath directly, which is how unreachable code came
to look like a working feature. Relative paths already resolve to the home
directory, so "notes.txt" covers what "~/notes.txt" was reaching for.

The repair is a pure function so it is covered on every platform rather
than only on the Windows job, and testSFTPNativeWindowsPath exercises the
whole path through a real sftp client where it can actually run.

* Address review of the guest-isolation and Windows path fixes

Five findings from review of #523, all in code this branch introduced.

MultiWriter split one lock into two rather than dropping it. Releasing
writeMu before the fan-out fixed Remove blocking behind a stuck guest, but
it also let two producers into the same writer at once, which races any
writer that is not concurrency safe and interleaves halves of two writes.
writeMu now serializes the fan-out and a separate membersMu guards the
slice, and membersMu is never held across a write, so Append and Remove
still never wait on a guest.

Append refuses a writer Remove could not match. Removal compares interface
values, and comparing two of the same non-comparable dynamic type panics.
Write removes the writers that failed it, so that panic would land in the
host's pty copy and take the process down on an ordinary disconnect. This
is not hypothetical here: TerminalQueryFilter holds slices and is only safe
because it is attached by pointer.

The stuck-writer test signalled from the calling goroutine, so Remove could
run before the writer had blocked and the test could pass without
exercising the bug at all. It now waits until the fan-out is inside the
writer. Against master's MultiWriter it fails, as it should.

undoubleDriveLetter read "/C:/dir/f:meta" as a drive. That is NTFS
alternate-data-stream syntax for the "meta" stream of the file "f", and
rewriting it silently retargeted the request at drive F:. A drive must now
be a whole path component: "/X:" followed by "/" or by end of string.

Windows exit statuses stay unsigned. GetExitCodeProcess returns a uint32
and the released windows/386 build has a 32-bit int, so a status with the
high bit set -- an unhandled exception like 0xC0000005, or "exit -1" --
became negative, and exitCode read that as a process that never exited
under its own control and reported a flat 1. ExitError.Code is a uint32
now, and the "did not exit" check is left to exec, which is the only source
of the -1 it was written for.

Each new test fails against the code it covers and passes after, except the
32-bit exit status, which cannot fail on a 64-bit build; there it documents
the invariant that windows/386 depends on.

One finding is declined. Codex reports that removing tilde handling breaks
"~/dest" for Rename, Symlink and Link because Request.Target is left
verbatim. It is not: pkg/sftp resolves Target through cleanPathWithBase for
exactly those three methods (request.go:202, 206, 209), so a target arrives
absolute and can no more begin with "~" than Filepath can.

* Ask whether the writer value is comparable, not its type

The check added for unremovable writers asked reflect.Type.Comparable, which
answers a different question than the one Remove needs. A struct wrapping an
io.Writer is a comparable type -- an interface field is one -- so the check
passed it, and comparing two of them still panicked when the writers inside
were funcs. That is not a contrived shape: a decorator holding an io.Writer
is what TerminalQueryFilter is, and it is only safe because the host attaches
it by pointer.

reflect.Value.Comparable walks into the interface and answers for what is
actually in it, and guarantees the comparison will not panic when it says
yes. Because the value we validate is the value we store -- the interface
holds a copy -- there is no window between the check and the comparison.

Table-driven now, over both bare and wrapped forms of every non-comparable
kind, plus the case that keeps the check from over-rejecting: the same
wrapper around a pointer is attached, written to, and removed by identity.
The two wrapped cases fail against the type-based check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants