Merge pull request #13767 from esengine/a5/wsl-link

feat(remote): bring up a kernel in a WSL distribution and reach it over loopback
This commit is contained in:
YHH authored and GitHub committed 2026-10-18 03:08:05 -07:00
commit 0363720bd2
23 files changed
+1808 -23

No files matched your search

+12 -2
View File
@@ -458,8 +458,18 @@ A WSL distribution on a Windows machine is a host with `kind = "wsl"` and a `dis
- It has no address, so it takes only the folder and install fields. `host`, `port`, `user`, keys, passwords,
`proxy_jump`, `use_ssh_config` and forwards are rejected on it.
- Connecting to one is not available yet. An SSH dial of such an entry fails with `remote: host is not reached over
ssh` rather than guessing an address.
- Studio opens it like any other machine in its list. Connecting needs WSL 2 and a distribution that has `ps`
and `nohup`; a minimal image without them is refused with the missing name.
- The kernel runs inside a shell session Studio keeps open, because WSL ends everything a session started when it ends.
- When Studio closes its link, the kernel goes away with the distribution (WSL stops an idle one after about 15
seconds).
- Conversations are kept on disk and resume on the next connect.
- Nothing keeps a distribution running behind your back.
- Model credentials come from the distribution's own configuration (`provider = "remote"`): under WSL's default
networking the distribution cannot reach this machine's loopback, so this machine's provider is not shared.
- A folder chosen as a Windows path (`C:\work\app` or `\\wsl.localhost\<distro>\home\me\app`) is translated by
the distribution; a folder it cannot see is refused. The command-line `reasonix remote connect` still dials
SSH only: such an entry fails with `remote: host is not reached over ssh`.
CLI:
+45 -8
View File
@@ -21,6 +21,7 @@ import (
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/bootstrap"
"reasonix/internal/platform/remote/forward"
"reasonix/internal/platform/wsl"
)
// Broker is this machine's provider broker: the loopback address it listens on
@@ -51,6 +52,9 @@ type Options struct {
// which is the only part of an attach that needs a configured machine, so
// substituting it is what lets the rest be exercised against a real one.
Dial func(host string, prompts Prompts) (*remote.Client, error)
// DialWSL builds a WSL machine's connection. Nil binds the distribution the
// entry names through the system wsl.exe.
DialWSL func(entry config.RemoteHostEntry) (Link, error)
}
// Call is what one attach decides for itself: where the progress of a first
@@ -120,7 +124,7 @@ func (g *gate) wait(ctx context.Context) error {
type link struct {
gate
host string
client *remote.Client
client Link
install string // the host entry's own strategy, read once at dial
// provider is where this host's model credentials come from, read at dial
// beside install. A host set to resolve its own gets no broker forward.
@@ -170,7 +174,7 @@ func (p *Pool) broker(l *link) (string, error) {
}
// boundBroker is the remote address the broker forward is listening on.
func boundBroker(client *remote.Client) (string, bool) {
func boundBroker(client Link) (string, bool) {
for _, e := range client.Forwards().List() {
if e.Spec.Name == brokerForwardName && e.Up && e.BoundAddr != "" {
return e.BoundAddr, true
@@ -181,7 +185,7 @@ func boundBroker(client *remote.Client) (string, bool) {
// followBroker points every kernel on l at the broker's forward after the link
// comes back. The kernels outlive the link; the port they were told may not.
func (p *Pool) followBroker(l *link, client *remote.Client) {
func (p *Pool) followBroker(l *link, client Link) {
if !p.brokers(l) {
return
}
@@ -349,15 +353,20 @@ func (p *Pool) dial(l *link, call Call) (unsubscribe func()) {
fail(err)
return nil
}
if entry, ok := cfg.RemoteHost(l.host); ok {
entry, configured := cfg.RemoteHost(l.host)
if configured {
l.install = entry.ServeInstallMode()
l.provider = entry.ProviderMode()
}
build := p.opts.Dial
if build == nil {
build = func(host string, prompts Prompts) (*remote.Client, error) { return Dial(cfg, host, prompts) }
var client Link
if configured && entry.HostKind() == config.RemoteKindWSL {
// A distribution has no tunnel to publish this machine's broker through,
// so it resolves providers from its own configuration.
l.provider = config.RemoteProviderRemote
client, err = p.openWSL(entry)
} else {
client, err = p.openSSH(cfg, l.host)
}
client, err := build(l.host, p.opts.Prompts)
if err != nil {
fail(err)
return nil
@@ -387,6 +396,25 @@ func (p *Pool) dial(l *link, call Call) (unsubscribe func()) {
return unsubscribe
}
func (p *Pool) openSSH(cfg *config.Config, host string) (Link, error) {
build := p.opts.Dial
if build == nil {
build = func(host string, prompts Prompts) (*remote.Client, error) { return Dial(cfg, host, prompts) }
}
c, err := build(host, p.opts.Prompts)
if err != nil {
return nil, err
}
return sshLink{c}, nil
}
func (p *Pool) openWSL(entry config.RemoteHostEntry) (Link, error) {
if p.opts.DialWSL != nil {
return p.opts.DialWSL(entry)
}
return newWSLLink(p.ctx, entry, wsl.System(), wsl.SystemCommand())
}
func (p *Pool) forgetLink(l *link) {
p.mu.Lock()
defer p.mu.Unlock()
@@ -430,6 +458,15 @@ func (p *Pool) holdSpace(l *link, workspace string) (*space, bool) {
// that reaches it.
func (p *Pool) serve(ctx context.Context, l *link, s *space, workspace string, call Call) {
defer close(s.ready)
if m, ok := l.client.(workspaceMapper); ok {
mapped, err := m.Workspace(ctx, workspace)
if err != nil {
s.err = err
p.forgetSpace(l, s)
return
}
workspace = mapped
}
install := p.opts.Install
if install == "" {
install = l.install
+1 -12
View File
@@ -59,21 +59,10 @@ func (p *Pool) Browse(ctx context.Context, host, dir string) (Listing, error) {
// Released on a timer, not at return — see browseLinger.
defer func() { time.AfterFunc(browseLinger, func() { p.dropLink(l) }) }()
fsys, err := l.client.SFTP()
if err != nil {
return Listing{}, err
}
if strings.TrimSpace(dir) == "" {
dir = "~"
}
// The far machine resolves it: ~ belongs to the account that logged in, and
// a symlinked project must be listed under the path it really has, or
// stepping up from it would leave the tree it was reached through.
at, err := fsys.RealPath(ctx, dir)
if err != nil {
return Listing{}, err
}
entries, err := fsys.List(ctx, at)
at, entries, err := l.client.Dirs(ctx, dir)
if err != nil {
return Listing{}, err
}
+59
View File
@@ -0,0 +1,59 @@
package attach
import (
"context"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/bootstrap"
"reasonix/internal/platform/remote/forward"
"reasonix/internal/platform/remote/sftpfs"
)
// Link is one machine's connection as the pool uses it: commands and files for
// the bootstrap, a way to reach a port that machine listens on, and a state to
// report. An SSH client is one, and a WSL distribution is the other.
type Link interface {
bootstrap.Conn
Start(ctx context.Context) error
Close() error
Subscribe(fn func(remote.StatusEvent)) (cancel func())
Forwards() Forwards
// Dirs resolves dir (empty or ~ meaning the login home) on that machine and
// lists what is inside it, in that machine's own spelling.
Dirs(ctx context.Context, dir string) (at string, entries []sftpfs.Entry, err error)
}
// workspaceMapper is a Link whose machine spells folders its own way, so a path
// chosen on this one has to be translated before the kernel there is asked to
// open it.
type workspaceMapper interface {
Workspace(ctx context.Context, p string) (string, error)
}
// Forwards is the part of a forward registry the pool drives.
type Forwards interface {
Add(spec forward.Spec) (string, error)
List() []forward.Entry
Remove(name string) error
}
// sshLink adapts a supervised SSH client to Link.
type sshLink struct{ *remote.Client }
func (l sshLink) Forwards() Forwards { return l.Client.Forwards() }
func (l sshLink) Dirs(ctx context.Context, dir string) (string, []sftpfs.Entry, error) {
fsys, err := l.SFTP()
if err != nil {
return "", nil, err
}
// The far machine resolves it: ~ belongs to the account that logged in, and
// a symlinked project must be listed under the path it really has, or
// stepping up from it would leave the tree it was reached through.
at, err := fsys.RealPath(ctx, dir)
if err != nil {
return "", nil, err
}
entries, err := fsys.List(ctx, at)
return at, entries, err
}
+315
View File
@@ -0,0 +1,315 @@
package attach
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"strconv"
"strings"
"sync"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/forward"
"reasonix/internal/platform/remote/wslconn"
"reasonix/internal/platform/wsl"
)
// ErrNoRemoteForward: a WSL distribution has no channel to publish a port of
// this machine into, so the one thing that would ask for it must not be sent.
var ErrNoRemoteForward = errors.New("attach: a WSL machine cannot be given a port of this one")
// wslLink is a WSL distribution as a Link. The distribution's loopback is
// reachable from here, so a "forward" is the address itself: nothing listens
// on this side and nothing has to be torn down.
type wslLink struct {
*wslconn.Conn
host string
anchorStop context.CancelFunc
anchorDone chan struct{}
anchorIn io.Writer // guarded by mu; nil while no session is up
anchorUp chan struct{}
upOnce sync.Once
mu sync.Mutex
subs map[int]func(remote.StatusEvent)
next int
last remote.StatusEvent
have bool
}
func newWSLLink(ctx context.Context, entry config.RemoteHostEntry, list wsl.Runner, run wsl.Command) (Link, error) {
conn, err := wslconn.New(ctx, list, run, entry.Distro)
if err != nil {
return nil, err
}
return &wslLink{Conn: conn, host: entry.Name, subs: map[int]func(remote.StatusEvent){}, anchorUp: make(chan struct{})}, nil
}
func (l *wslLink) publish(ev remote.StatusEvent) {
ev.Host, ev.At = l.host, time.Now()
l.mu.Lock()
l.last, l.have = ev, true
fns := make([]func(remote.StatusEvent), 0, len(l.subs))
for _, fn := range l.subs {
fns = append(fns, fn)
}
l.mu.Unlock()
for _, fn := range fns {
fn(ev)
}
}
// requiredTools prints the first program a kernel launch depends on that the
// distribution lacks and stops with exitMissingTool. Minimal images omit ps,
// which is how the launched process is identified as the kernel.
const (
requiredTools = `for t in ps nohup; do command -v "$t" >/dev/null 2>&1 || { echo "$t"; exit 46; }; done`
exitMissingTool = 46
)
// Workspace spells a folder the way the distribution sees it: a Windows path
// is translated by the distribution itself, anything else is already its own.
func (l *wslLink) Workspace(ctx context.Context, p string) (string, error) {
if !wsl.IsWindowsPath(p) {
return p, nil
}
return l.ToLinux(ctx, p)
}
// Start wakes the distribution: the first command is what boots it, and a
// distribution that cannot answer one is reported as the connect failing.
func (l *wslLink) Start(ctx context.Context) error {
l.publish(remote.StatusEvent{Status: remote.StatusConnecting})
res, err := l.Exec(ctx, requiredTools)
switch {
case err != nil:
case res.ExitCode == exitMissingTool:
err = fmt.Errorf("%w: %q has no %s, which starting a kernel there needs", wsl.ErrUnsupported, l.Distro(), strings.TrimSpace(string(res.Stdout)))
case res.ExitCode != 0:
err = fmt.Errorf("%w: %q answered with status %d", wsl.ErrUnsupported, l.Distro(), res.ExitCode)
}
if err != nil {
l.publish(remote.StatusEvent{Status: remote.StatusStopped, Err: err})
return err
}
l.publish(remote.StatusEvent{Status: remote.StatusConnected})
anchorCtx, stop := context.WithCancel(context.WithoutCancel(ctx))
l.anchorStop, l.anchorDone = stop, make(chan struct{})
go l.holdAnchor(anchorCtx)
select {
case <-l.anchorUp:
case <-ctx.Done():
stop()
<-l.anchorDone
return ctx.Err()
}
return nil
}
// anchorRetry is how long a lost anchor waits before the distribution is asked
// for another.
var anchorRetry = 2 * time.Second
// holdAnchor keeps one shell session open and feeds it the commands that must
// outlive the call that sent them: WSL ends everything a wsl.exe session
// started when it ends, setsid and nohup included. A lost session is asked for
// again, and the kernel it held is gone.
func (l *wslLink) holdAnchor(ctx context.Context) {
defer close(l.anchorDone)
for attempt := 1; ctx.Err() == nil; attempt++ {
pr, pw := io.Pipe()
l.mu.Lock()
l.anchorIn = pw
l.mu.Unlock()
l.upOnce.Do(func() { close(l.anchorUp) })
sessionDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = pw.Close()
case <-sessionDone:
}
}()
_, err := l.Run(ctx, pr, "/bin/sh")
close(sessionDone)
l.mu.Lock()
l.anchorIn = nil
l.mu.Unlock()
_ = pw.Close()
if ctx.Err() != nil {
return
}
if err == nil {
err = fmt.Errorf("%w: the session holding %q open ended", wsl.ErrUnsupported, l.Distro())
}
l.publish(remote.StatusEvent{Status: remote.StatusReconnecting, Attempt: attempt, Err: err})
select {
case <-ctx.Done():
return
case <-time.After(anchorRetry):
}
l.publish(remote.StatusEvent{Status: remote.StatusConnected, Attempt: attempt})
}
}
// ErrNoSession: the session that launches long-lived processes is not up, or
// ended before the command it was given finished.
var ErrNoSession = errors.New("attach: the WSL session that keeps a kernel alive is not open")
// ErrUnsafeScratch: the directory launch commands are staged in is not the
// current user's private directory, so what is written there could be read or
// replaced by someone else.
var ErrUnsafeScratch = errors.New("attach: the WSL launch directory is not private to this user")
// detachedPoll is how often a launched command is asked whether it finished.
const detachedPoll = 100 * time.Millisecond
const (
exitUnsafeScratch = 47
exitNotYet = 44
// scratch is a directory under the account's home that must be a real
// directory it owns and nobody else can enter. A path under /tmp would be
// accepted by mkdir -p whoever had made it.
scratch = `d="$HOME/.reasonix/link"`
)
// ExecDetached runs cmd inside the held session, so what it starts is not
// ended when this call returns. The command is written to a file and only its
// name is typed into the session, which keeps the script's text out of the
// session's input. The wait ends when the command does or when the session it
// was given to does, whichever is first.
func (l *wslLink) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
var raw [8]byte
_, _ = rand.Read(raw[:])
id := hex.EncodeToString(raw[:])
put, err := l.Script(ctx, strings.NewReader(cmd), scratch+`
mkdir -p -m 700 "$d" || exit 1
[ -d "$d" ] && [ ! -L "$d" ] && [ "$(stat -c %u "$d")" = "$(id -u)" ] && [ "$(stat -c %a "$d")" = 700 ] || exit 47
cat > "$d/$1.sh"`, id)
if err != nil {
return remote.ExecResult{}, err
}
switch put.ExitCode {
case 0:
case exitUnsafeScratch:
return remote.ExecResult{}, ErrUnsafeScratch
default:
return remote.ExecResult{}, fmt.Errorf("attach: stage the launch command: exit %d: %s", put.ExitCode, strings.TrimSpace(string(put.Stderr)))
}
in := l.session()
if in == nil {
return remote.ExecResult{}, ErrNoSession
}
line := fmt.Sprintf(scratch+`; /bin/sh "$d/%[1]s.sh" </dev/null >"$d/%[1]s.out" 2>"$d/%[1]s.err"; echo $? >"$d/%[1]s.rc"`+"\n", id)
wrote := make(chan error, 1)
go func() { _, err := io.WriteString(in, line); wrote <- err }()
select {
case err := <-wrote:
if err != nil {
return remote.ExecResult{}, fmt.Errorf("%w: %w", ErrNoSession, err)
}
case <-ctx.Done():
return remote.ExecResult{}, ctx.Err()
}
for {
res, err := l.Script(ctx, nil, scratch+`; [ -s "$d/$1.rc" ] || exit 44`, id)
if err != nil {
return remote.ExecResult{}, err
}
if res.ExitCode != exitNotYet {
break
}
if l.session() != in {
return remote.ExecResult{}, ErrNoSession
}
select {
case <-ctx.Done():
return remote.ExecResult{}, ctx.Err()
case <-time.After(detachedPoll):
}
}
return l.collect(ctx, id)
}
// session is the input of the shell session currently held, nil when none is.
func (l *wslLink) session() io.Writer {
l.mu.Lock()
defer l.mu.Unlock()
return l.anchorIn
}
// collect reads what a finished launch printed and how it ended, then removes
// its files. The status is read from its own file, so no exit code of the
// command can be mistaken for the poll's "not yet".
func (l *wslLink) collect(ctx context.Context, id string) (remote.ExecResult, error) {
read := func(name string) ([]byte, error) {
res, err := l.Script(ctx, nil, scratch+`; cat "$d/$1.`+name+`"`, id)
return res.Stdout, err
}
out, err := read("out")
if err != nil {
return remote.ExecResult{}, err
}
errb, err := read("err")
if err != nil {
return remote.ExecResult{}, err
}
rc, err := read("rc")
if err != nil {
return remote.ExecResult{}, err
}
_, _ = l.Script(ctx, nil, scratch+`; rm -f "$d/$1.sh" "$d/$1.out" "$d/$1.err" "$d/$1.rc"`, id)
code, err := strconv.Atoi(strings.TrimSpace(string(rc)))
if err != nil {
return remote.ExecResult{}, fmt.Errorf("attach: the launch left no readable status: %w", err)
}
return remote.ExecResult{Stdout: out, Stderr: errb, ExitCode: code}, nil
}
func (l *wslLink) Close() error {
if l.anchorStop != nil {
l.anchorStop()
<-l.anchorDone
}
l.publish(remote.StatusEvent{Status: remote.StatusStopped})
return nil
}
func (l *wslLink) Subscribe(fn func(remote.StatusEvent)) func() {
l.mu.Lock()
id := l.next
l.next++
l.subs[id] = fn
last, have := l.last, l.have
l.mu.Unlock()
if have {
fn(last)
}
return func() {
l.mu.Lock()
delete(l.subs, id)
l.mu.Unlock()
}
}
func (l *wslLink) Forwards() Forwards { return loopbackForwards{} }
// loopbackForwards reaches a port on the distribution's loopback directly.
type loopbackForwards struct{}
func (loopbackForwards) Add(spec forward.Spec) (string, error) {
if spec.Direction != forward.Local {
return "", ErrNoRemoteForward
}
return spec.TargetAddr, nil
}
func (loopbackForwards) List() []forward.Entry { return nil }
func (loopbackForwards) Remove(string) error { return nil }
@@ -0,0 +1,73 @@
//go:build windows
package attach
import (
"net/http"
"os"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/platform/wsl"
)
// REASONIX_LIVE_WSL names an installed WSL2 distribution that already has a
// reasonix 2.x on its PATH (install "never"); the test brings up a kernel in it
// through the pool and calls it from Windows over loopback.
func TestLiveAttachBringsUpAKernelInADistribution(t *testing.T) {
name := os.Getenv("REASONIX_LIVE_WSL")
if name == "" {
t.Skip("set REASONIX_LIVE_WSL to a WSL2 distribution with reasonix installed")
}
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
return nil, c.UpsertRemoteHost(config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: name, ServeInstall: "never"})
}); err != nil {
t.Fatal(err)
}
if _, err := wsl.SystemCommand()(t.Context(), nil, wsl.ExecArgs(name, "/bin/mkdir", "-p", "/tmp/reasonix-live-workspace")...); err != nil {
t.Fatal(err)
}
p := NewPool(t.Context(), Options{Install: "never"})
defer p.Close()
ep, err := p.Attach(t.Context(), "dev", "/tmp/reasonix-live-workspace", Call{Progress: func(step, detail string) { t.Logf("step %s %s", step, detail) }})
if err != nil {
t.Fatalf("Attach: %v", err)
}
t.Logf("kernel at %s workspace %s", ep.Addr, ep.Workspace)
req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+ep.Addr+"/", nil)
req.Header.Set("Authorization", "Bearer "+ep.Token)
var resp *http.Response
for range 20 {
if resp, err = http.DefaultClient.Do(req); err == nil {
break
}
time.Sleep(500 * time.Millisecond)
}
if err != nil {
t.Fatalf("the kernel did not answer over loopback: %v", err)
}
resp.Body.Close()
t.Logf("GET / = %d", resp.StatusCode)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode >= 500 {
t.Fatalf("the kernel refused the token it was started with: %d", resp.StatusCode)
}
// A second pane on the same workspace shares the one kernel.
again, err := p.Attach(t.Context(), "dev", "/tmp/reasonix-live-workspace", Call{})
if err != nil || again.Addr != ep.Addr {
t.Fatalf("second attach = %+v, %v; want the same address %s", again, err, ep.Addr)
}
again.Release()
// The same folder named the Windows way reaches the same kernel.
unc, err := p.Attach(t.Context(), "dev", `\\wsl.localhost\`+name+`\tmp\reasonix-live-workspace`, Call{})
if err != nil || unc.Addr != ep.Addr || unc.Workspace != ep.Workspace {
t.Fatalf("UNC attach = %+v, %v; want the kernel at %s for %s", unc, err, ep.Addr, ep.Workspace)
}
unc.Release()
ep.Release()
}
@@ -0,0 +1,126 @@
//go:build linux
package attach
import (
"bytes"
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/wsl"
)
// localSession answers wsl.exe's --exec by running the program here with HOME
// pointing at a directory the test owns, so the staging scripts and the session
// shell meet a real GNU userland.
func localSession(home string) wsl.Command {
return func(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
for i, a := range args {
if a == "--exec" {
args = args[i+1:]
break
}
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
cmd.Env = append(os.Environ(), "HOME="+home)
var out, errb bytes.Buffer
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, &out, &errb
err := cmd.Run()
res := wsl.Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode, err = ee.ExitCode(), nil
}
return res, err
}
}
func localLink(t *testing.T, home string) Link {
t.Helper()
if err := exec.Command("stat", "-c", "%a", "/").Run(); err != nil {
t.Skip("needs GNU stat")
}
m := &wslMachine{t: t}
l, err := newWSLLink(t.Context(), config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: "Ubuntu"}, m.list, localSession(home))
if err != nil {
t.Fatal(err)
}
return l
}
func detach(l Link) func(context.Context, string) (remote.ExecResult, error) {
return l.(interface {
ExecDetached(context.Context, string) (remote.ExecResult, error)
}).ExecDetached
}
func TestALaunchRunsInARealSessionAndKeepsItsOwnStatus(t *testing.T) {
l := localLink(t, t.TempDir())
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
res, err := detach(l)(t.Context(), "echo out; echo err >&2; exit 44")
if err != nil || string(res.Stdout) != "out\n" || string(res.Stderr) != "err\n" || res.ExitCode != 44 {
t.Fatalf("ExecDetached = %+v, %v", res, err)
}
}
// mkdir -p succeeds on a directory someone else prepared, so the staging
// directory is checked after it is made and never trusted for existing.
func TestAScratchDirectoryOthersCouldUseIsRefused(t *testing.T) {
for name, prepare := range map[string]func(t *testing.T, dir string){
"group and world accessible": func(t *testing.T, dir string) {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0o755); err != nil {
t.Fatal(err)
}
},
"world writable": func(t *testing.T, dir string) {
if err := os.MkdirAll(dir, 0o777); err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatal(err)
}
},
"a symlink to elsewhere": func(t *testing.T, dir string) {
if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
t.Fatal(err)
}
elsewhere := t.TempDir()
if err := os.Chmod(elsewhere, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(elsewhere, dir); err != nil {
t.Fatal(err)
}
},
} {
t.Run(name, func(t *testing.T) {
home := t.TempDir()
prepare(t, filepath.Join(home, ".reasonix", "link"))
l := localLink(t, home)
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
if _, err := detach(l)(t.Context(), "echo hi"); !errors.Is(err, ErrUnsafeScratch) {
t.Fatalf("err = %v, want ErrUnsafeScratch", err)
}
entries, _ := os.ReadDir(filepath.Join(home, ".reasonix", "link"))
for _, e := range entries {
t.Errorf("a command was staged in the unsafe directory: %s", e.Name())
}
})
}
}
+342
View File
@@ -0,0 +1,342 @@
package attach
import (
"context"
"errors"
"io"
"slices"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/forward"
"reasonix/internal/platform/wsl"
)
// wslMachine is a fake wsl.exe: it lists one distribution and records the
// commands it is asked to run.
type wslMachine struct {
t *testing.T
version int
mu sync.Mutex
calls [][]string
// anchor decides how the long-lived session behaves; nil blocks until cancelled.
anchor func(ctx context.Context, stdin io.Reader) (wsl.Result, error)
// script answers every other command; nil answers success with no output.
script func(args []string, stdin io.Reader) (wsl.Result, error)
// typed collects what the session was sent.
typed strings.Builder
typedMu sync.Mutex
// started gets one value each time the long-lived session is launched.
started chan struct{}
}
func (m *wslMachine) list(context.Context, ...string) ([]byte, error) {
v := m.version
if v == 0 {
v = 2
}
return []byte(" NAME STATE VERSION\n* Ubuntu Running " + string(rune('0'+v)) + "\n"), nil
}
func (m *wslMachine) run(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
m.mu.Lock()
m.calls = append(m.calls, slices.Clone(args))
m.mu.Unlock()
if slices.Equal(args, []string{"-d", "Ubuntu", "--exec", "/bin/sh"}) {
if m.started != nil {
m.started <- struct{}{}
}
if m.anchor != nil {
return m.anchor(ctx, stdin)
}
return m.session(ctx, stdin)
}
if m.script != nil {
return m.script(args, stdin)
}
return wsl.Result{}, nil
}
// session is a shell that reads its commands until it is cancelled or its
// input closes, the way /bin/sh does.
func (m *wslMachine) session(ctx context.Context, stdin io.Reader) (wsl.Result, error) {
done := make(chan struct{})
go func() {
buf := make([]byte, 4096)
for {
n, err := stdin.Read(buf)
m.typedMu.Lock()
m.typed.Write(buf[:n])
m.typedMu.Unlock()
if err != nil {
close(done)
return
}
}
}()
select {
case <-ctx.Done():
return wsl.Result{}, ctx.Err()
case <-done:
return wsl.Result{}, nil
}
}
func (m *wslMachine) link(entry config.RemoteHostEntry) (Link, error) {
return newWSLLink(m.t.Context(), entry, m.list, m.run)
}
func wslEntry() config.RemoteHostEntry {
return config.RemoteHostEntry{Name: "dev", Kind: "wsl", Distro: "Ubuntu"}
}
func TestAWSLLinkIsBoundToAnInstalledWSL2Distribution(t *testing.T) {
m := &wslMachine{t: t}
if _, err := m.link(config.RemoteHostEntry{Name: "x", Kind: "wsl", Distro: "Nope"}); !errors.Is(err, wsl.ErrNoDistro) {
t.Fatalf("unknown distribution = %v", err)
}
m.version = 1
if _, err := m.link(wslEntry()); !errors.Is(err, wsl.ErrUnsupported) {
t.Fatalf("WSL1 = %v, want ErrUnsupported", err)
}
m.version = 2
if _, err := m.link(wslEntry()); err != nil {
t.Fatal(err)
}
}
func TestStartingAWSLLinkHoldsOneSessionOpenUntilClose(t *testing.T) {
m := &wslMachine{t: t, started: make(chan struct{}, 4)}
l, err := m.link(wslEntry())
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var seen []remote.Status
l.Subscribe(func(ev remote.StatusEvent) { mu.Lock(); seen = append(seen, ev.Status); mu.Unlock() })
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
<-m.started
if err := l.Close(); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if !slices.Equal(seen, []remote.Status{remote.StatusConnecting, remote.StatusConnected, remote.StatusStopped}) {
t.Fatalf("states = %v", seen)
}
var anchors int
for _, c := range m.calls {
if slices.Equal(c, []string{"-d", "Ubuntu", "--exec", "/bin/sh"}) {
anchors++
}
}
if anchors != 1 {
t.Fatalf("%d sessions were held open, want 1", anchors)
}
}
func TestALostSessionIsReportedAndAskedForAgain(t *testing.T) {
defer func(d time.Duration) { anchorRetry = d }(anchorRetry)
anchorRetry = time.Millisecond
var starts int
var mu sync.Mutex
m := &wslMachine{t: t, started: make(chan struct{}, 4), anchor: func(ctx context.Context, _ io.Reader) (wsl.Result, error) {
mu.Lock()
starts++
n := starts
mu.Unlock()
if n == 1 {
return wsl.Result{}, nil
}
<-ctx.Done()
return wsl.Result{}, ctx.Err()
}}
l, _ := m.link(wslEntry())
events := make(chan remote.StatusEvent, 16)
l.Subscribe(func(ev remote.StatusEvent) { events <- ev })
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
deadline := time.After(5 * time.Second)
var sawLost bool
for !sawLost {
select {
case ev := <-events:
sawLost = ev.Status == remote.StatusReconnecting && ev.Err != nil && ev.Attempt == 1
case <-deadline:
t.Fatal("a session that ended was not reported")
}
}
<-m.started
<-m.started
_ = l.Close()
mu.Lock()
defer mu.Unlock()
if starts != 2 {
t.Fatalf("the session was started %d times, want 2", starts)
}
}
func TestAWSLLinkReachesTheKernelDirectlyAndRefusesAnInboundPort(t *testing.T) {
f := loopbackForwards{}
addr, err := f.Add(forward.Spec{Direction: forward.Local, BindAddr: "127.0.0.1:0", TargetAddr: "127.0.0.1:4242"})
if err != nil || addr != "127.0.0.1:4242" {
t.Fatalf("Add local = %q, %v", addr, err)
}
if _, err := f.Add(forward.Spec{Direction: forward.Remote, BindAddr: "127.0.0.1:0", TargetAddr: "127.0.0.1:1"}); !errors.Is(err, ErrNoRemoteForward) {
t.Fatalf("Add remote = %v, want ErrNoRemoteForward", err)
}
if len(f.List()) != 0 || f.Remove("x") != nil {
t.Fatal("a direct route has nothing to list or remove")
}
}
// A WSL entry resolves providers on the distribution: a broker configured on
// this pool must not be published to it.
func TestAPoolOpensAWSLEntryThroughTheWSLLinkAndSendsNoBroker(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
if err := config.EditUserConfigWithCredentials(func(c *config.Config) ([]config.CredentialChange, error) {
return nil, c.UpsertRemoteHost(wslEntry())
}); err != nil {
t.Fatal(err)
}
m := &wslMachine{t: t}
var opened int
p := NewPool(t.Context(), Options{
Broker: Broker{Addr: "127.0.0.1:9", Token: "t"},
Dial: func(string, Prompts) (*remote.Client, error) {
t.Error("an SSH dial was made for a WSL entry")
return nil, errors.New("no")
},
DialWSL: func(e config.RemoteHostEntry) (Link, error) { opened++; return m.link(e) },
})
defer p.Close()
l, dialer := p.holdLink("dev")
if !dialer {
t.Fatal("first hold must dial")
}
p.dial(l, Call{})
if err := l.wait(t.Context()); err != nil {
t.Fatal(err)
}
if opened != 1 || p.brokers(l) {
t.Fatalf("opened = %d, brokers = %v", opened, p.brokers(l))
}
}
// What a launch starts must belong to the held session, not to the call that
// sent it: the command is staged in a file, only its name is typed into the
// session, and the call returns what the command printed and its own status.
func TestADetachedCommandRunsInTheHeldSessionAndReturnsItsOutput(t *testing.T) {
var staged string
var polls int
m := &wslMachine{t: t}
m.script = func(args []string, stdin io.Reader) (wsl.Result, error) {
body := args[5]
switch {
case strings.Contains(body, `cat > "$d/$1.sh"`):
b, _ := io.ReadAll(stdin)
staged = string(b)
case strings.Contains(body, `exit 44`):
polls++
if polls < 3 {
return wsl.Result{ExitCode: 44}, nil
}
case strings.HasSuffix(body, `.out"`):
return wsl.Result{Stdout: []byte("12345\n")}, nil
case strings.HasSuffix(body, `.err"`):
return wsl.Result{Stdout: []byte("warned\n")}, nil
case strings.HasSuffix(body, `.rc"`):
return wsl.Result{Stdout: []byte("44\n")}, nil
}
return wsl.Result{}, nil
}
l, _ := m.link(wslEntry())
d := l.(interface {
ExecDetached(context.Context, string) (remote.ExecResult, error)
})
if _, err := d.ExecDetached(t.Context(), "x"); !errors.Is(err, ErrNoSession) {
t.Fatalf("before the session is up: %v, want ErrNoSession", err)
}
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
res, err := d.ExecDetached(t.Context(), "nohup serve & echo $!")
if err != nil || string(res.Stdout) != "12345\n" || string(res.Stderr) != "warned\n" || res.ExitCode != 44 {
t.Fatalf("ExecDetached = %+v, %v; a command's own status 44 must not read as 'not yet'", res, err)
}
if staged != "nohup serve & echo $!" || polls != 3 {
t.Fatalf("staged = %q, polls = %d", staged, polls)
}
deadline := time.Now().Add(5 * time.Second)
for {
m.typedMu.Lock()
typed := m.typed.String()
m.typedMu.Unlock()
if strings.Contains(typed, `$HOME/.reasonix/link`) && strings.HasSuffix(typed, "\n") && !strings.Contains(typed, "nohup") && !strings.Contains(typed, "/tmp") {
return
}
if time.Now().After(deadline) {
t.Fatalf("the session was sent %q, want a line naming the private staged file and none of its text", typed)
}
time.Sleep(10 * time.Millisecond)
}
}
// The wait for a launched command is bounded by the session it was given to:
// one that dies after the write leaves nothing that will ever finish.
func TestALaunchWhoseSessionDiesAfterTheWriteStopsWaiting(t *testing.T) {
m := &wslMachine{t: t}
m.anchor = func(ctx context.Context, stdin io.Reader) (wsl.Result, error) {
buf := make([]byte, 4096)
_, _ = stdin.Read(buf)
return wsl.Result{}, nil
}
m.script = func(args []string, _ io.Reader) (wsl.Result, error) {
if strings.Contains(args[5], `exit 44`) {
return wsl.Result{ExitCode: 44}, nil
}
return wsl.Result{}, nil
}
l, _ := m.link(wslEntry())
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
_, err := l.(interface {
ExecDetached(context.Context, string) (remote.ExecResult, error)
}).ExecDetached(ctx, "serve")
if !errors.Is(err, ErrNoSession) {
t.Fatalf("ExecDetached = %v, want ErrNoSession (not a wait until the deadline)", err)
}
}
func TestAScratchDirectoryThatIsNotPrivateIsRefusedWithATypedError(t *testing.T) {
m := &wslMachine{t: t, script: func(args []string, _ io.Reader) (wsl.Result, error) {
if strings.Contains(args[5], `cat > "$d/$1.sh"`) {
return wsl.Result{ExitCode: exitUnsafeScratch}, nil
}
return wsl.Result{}, nil
}}
l, _ := m.link(wslEntry())
if err := l.Start(t.Context()); err != nil {
t.Fatal(err)
}
defer l.Close()
_, err := l.(interface {
ExecDetached(context.Context, string) (remote.ExecResult, error)
}).ExecDetached(t.Context(), "serve")
if !errors.Is(err, ErrUnsafeScratch) {
t.Fatalf("ExecDetached = %v, want ErrUnsafeScratch", err)
}
}
@@ -32,6 +32,14 @@ type Conn interface {
Files() (sftpfs.Files, error)
}
// Detacher is a connection whose commands end every process they started when
// they return. Such a machine launches the kernel through ExecDetached, which
// starts it under something that outlives the call, so the kernel lives as long
// as that does.
type Detacher interface {
ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error)
}
// Install strategies.
const (
InstallAuto = "auto"
@@ -208,7 +216,11 @@ func launchServe(ctx context.Context, conn Conn, target remoteOS, fs sftpfs.File
_ = fs.Remove(cleanup, scriptPath, false)
}()
}
launchRes, err := conn.Exec(ctx, target.Launch(spec, paths))
launch := conn.Exec
if d, ok := conn.(Detacher); ok {
launch = d.ExecDetached
}
launchRes, err := launch(ctx, target.Launch(spec, paths))
if err != nil {
cleanupFailedLaunch(conn, target, fs, paths, 0)
return Result{}, fmt.Errorf("bootstrap: launch: %w", err)
@@ -742,3 +742,52 @@ func TestRepointBrokerRewritesOnlyAServeThatFollowsItsFile(t *testing.T) {
}
}
}
// detachConn is a machine that ends what a command started when the command
// returns: the launch must go through ExecDetached, never plain Exec.
type detachConn struct {
*fakeConn
t *testing.T
detached []string
}
func (d *detachConn) Exec(ctx context.Context, cmd string) (remote.ExecResult, error) {
if strings.Contains(cmd, "nohup") {
d.t.Errorf("the launch went through Exec: %s", cmd)
}
return d.fakeConn.Exec(ctx, cmd)
}
func (d *detachConn) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
d.detached = append(d.detached, cmd)
return d.fakeConn.Exec(ctx, cmd)
}
func TestEnsureServeLaunchesThroughTheDetachedPathWhenTheMachineAsksForIt(t *testing.T) {
skipOnWindows(t)
root := testenv.TempDir(t)
var portFile string
inner := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
switch {
case strings.Contains(cmd, "uname"):
return ok("Linux x86_64\n")
case strings.Contains(cmd, "command -v reasonix"):
return ok("bin /usr/bin/reasonix\nver reasonix v9.9.0\n" + allFlagsYes())
case strings.Contains(cmd, "nohup"):
_ = os.WriteFile(portFile, []byte("127.0.0.1:44321\n"), 0o600)
return ok("54321\n")
case strings.Contains(cmd, "ps -p 54321"):
return ok("1\n")
}
return ok("")
})
portFile = pathsFor(root, root).PortFile
conn := &detachConn{fakeConn: inner, t: t}
res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", MinVersion: "1.0.0", Clock: time.Now})
if err != nil {
t.Fatalf("EnsureServe: %v", err)
}
if res.State.PID != 54321 || len(conn.detached) != 1 || !strings.Contains(conn.detached[0], "nohup") {
t.Fatalf("pid = %d, detached = %v", res.State.PID, conn.detached)
}
}
@@ -131,6 +131,17 @@ func (c *envConn) Exec(ctx context.Context, cmd string) (remote.ExecResult, erro
return c.Conn.Exec(ctx, "export PATH="+shellQuote(strings.Join(c.env.Path, ":"))+`:"$PATH"; `+cmd)
}
// ExecDetached keeps the captured PATH on a launch and leaves it to the wrapped
// connection how the launched process is kept alive; one with nothing special
// to do runs it as any other command.
func (c *envConn) ExecDetached(ctx context.Context, cmd string) (remote.ExecResult, error) {
cmd = "export PATH=" + shellQuote(strings.Join(c.env.Path, ":")) + `:"$PATH"; ` + cmd
if d, ok := c.Conn.(Detacher); ok {
return d.ExecDetached(ctx, cmd)
}
return c.Conn.Exec(ctx, cmd)
}
// withLoginEnv wraps conn only when the capture produced a PATH.
func withLoginEnv(env loginEnv, conn Conn) Conn {
if env.Outcome != EnvCaptured {
@@ -341,3 +341,39 @@ func TestEachEntryPointCapturesOnceOnTheRawConnection(t *testing.T) {
})
}
}
type detachingConn struct {
scriptConn
detached []string
}
func (c *detachingConn) ExecDetached(_ context.Context, cmd string) (remote.ExecResult, error) {
c.detached = append(c.detached, cmd)
return remote.ExecResult{}, nil
}
// A machine that needs launches kept alive by something outside the call must
// still get them with the login PATH in front, and one that does not runs a
// launch as a plain command.
func TestAWrappedConnectionStillLaunchesThroughTheDetachedPath(t *testing.T) {
env := loginEnv{Outcome: EnvCaptured, Path: []string{"/opt/bin"}}
inner := &detachingConn{scriptConn: scriptConn{fn: func(string) (remote.ExecResult, error) { return remote.ExecResult{}, nil }}}
d, ok := withLoginEnv(env, inner).(Detacher)
if !ok {
t.Fatal("the wrapper hides the detached path")
}
if _, err := d.ExecDetached(context.Background(), "serve"); err != nil {
t.Fatal(err)
}
if len(inner.detached) != 1 || !strings.HasPrefix(inner.detached[0], "export PATH='/opt/bin':") || !strings.HasSuffix(inner.detached[0], "serve") || len(inner.execs) != 0 {
t.Fatalf("detached = %q, execs = %q", inner.detached, inner.execs)
}
plain := &scriptConn{fn: func(string) (remote.ExecResult, error) { return remote.ExecResult{}, nil }}
if _, err := withLoginEnv(env, plain).(Detacher).ExecDetached(context.Background(), "serve"); err != nil {
t.Fatal(err)
}
if len(plain.execs) != 1 || !strings.HasSuffix(plain.execs[0], "serve") {
t.Fatalf("a plain machine did not get the launch as a command: %q", plain.execs)
}
}
+86
View File
@@ -0,0 +1,86 @@
package wslconn
import (
"context"
"fmt"
"io"
"io/fs"
"strings"
"reasonix/internal/platform/remote"
"reasonix/internal/platform/remote/sftpfs"
"reasonix/internal/platform/wsl"
)
// Conn is one distribution as a bootstrap connection.
type Conn struct {
distro string
run wsl.Command
}
// New binds a connection to an installed distribution. The name must be one
// wsl.exe lists, which keeps anything else out of its argument list.
func New(ctx context.Context, list wsl.Runner, run wsl.Command, distro string) (*Conn, error) {
all, err := wsl.List(ctx, list)
if err != nil {
return nil, err
}
for _, d := range all {
if d.Name == distro {
if d.Version != 2 {
return nil, fmt.Errorf("%w: %q is WSL%d", wsl.ErrUnsupported, distro, d.Version)
}
return &Conn{distro: distro, run: run}, nil
}
}
return nil, fmt.Errorf("%w: %q", wsl.ErrNoDistro, distro)
}
// Distro is the distribution's name.
func (c *Conn) Distro() string { return c.distro }
// Exec runs cmd under /bin/sh in the distribution, as an SSH exec would run it
// under the account's shell.
func (c *Conn) Exec(ctx context.Context, cmd string) (remote.ExecResult, error) {
res, err := c.run(ctx, nil, wsl.ExecArgs(c.distro, "/bin/sh", "-c", cmd)...)
return remote.ExecResult{Stdout: res.Stdout, Stderr: res.Stderr, ExitCode: res.ExitCode}, err
}
// Files is the distribution's file layer.
func (c *Conn) Files() (sftpfs.Files, error) { return &files{c: c}, nil }
// Script runs body under /bin/sh with args as $1... and stdin.
func (c *Conn) Script(ctx context.Context, stdin io.Reader, body string, args ...string) (wsl.Result, error) {
return c.script(ctx, stdin, body, args...)
}
// Run starts a wsl.exe session that lives until ctx ends, feeding stdin to the
// program argv names, and returns when it ends.
func (c *Conn) Run(ctx context.Context, stdin io.Reader, argv ...string) (wsl.Result, error) {
return c.run(ctx, stdin, wsl.ExecArgs(c.distro, argv...)...)
}
// script runs body with args as $1... and stdin, returning its result.
func (c *Conn) script(ctx context.Context, stdin io.Reader, body string, args ...string) (wsl.Result, error) {
argv := append([]string{"/bin/sh", "-c", body, "sh"}, args...)
return c.run(ctx, stdin, wsl.ExecArgs(c.distro, argv...)...)
}
// Exit statuses of the file scripts. Anything else non-zero is a failure
// whose text is the tool's, kept for the log and never matched.
const (
exitMissing = 44
exitIsDir = 45
)
func failure(op string, res wsl.Result) error {
switch res.ExitCode {
case 0:
return nil
case exitMissing:
return fmt.Errorf("%s: %w", op, fs.ErrNotExist)
case exitIsDir:
return fmt.Errorf("%s: is a directory", op)
}
return fmt.Errorf("%s: exit %d: %s", op, res.ExitCode, strings.TrimSpace(string(res.Stderr)))
}
@@ -0,0 +1,20 @@
package wslconn
import (
"io/fs"
"testing"
)
func TestFileModeReadsTheRawBits(t *testing.T) {
for raw, want := range map[uint32]fs.FileMode{
0o100644: 0o644,
0o040755: fs.ModeDir | 0o755,
0o120777: fs.ModeSymlink | 0o777,
0o104755: fs.ModeSetuid | 0o755,
0o041777: fs.ModeDir | fs.ModeSticky | 0o777,
} {
if got := fileMode(raw); got != want {
t.Errorf("fileMode(%o) = %v, want %v", raw, got, want)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// Package wslconn lets the kernel bootstrap treat a WSL distribution as a
// machine it can run commands on and keep files in, the way it does an SSH host.
// Commands go through `wsl.exe --exec /bin/sh -c`, and file operations are
// small fixed POSIX scripts that take every path as a positional argument, so
// no path is ever spliced into script text. A missing path is reported by an
// exit status this package defines, never by reading a tool's message.
package wslconn
+195
View File
@@ -0,0 +1,195 @@
package wslconn
import (
"bytes"
"context"
"fmt"
"io/fs"
"path"
"strconv"
"strings"
"reasonix/internal/platform/remote/sftpfs"
)
type files struct{ c *Conn }
var _ sftpfs.Files = (*files)(nil)
const (
statScript = `[ -e "$1" ] || exit 44
exec stat -L -c '%s %f %Y' -- "$1"`
readScript = `[ -e "$1" ] || exit 44
[ -d "$1" ] && exit 45
exec head -c "$2" -- "$1"`
writeScript = `t="$(dirname -- "$1")/.$(basename -- "$1").reasonix-tmp-$$"
{ cat > "$t" && chmod "$2" "$t" && mv -f -- "$t" "$1"; } || { rm -f -- "$t"; exit 1; }`
mkdirAllScript = `exec mkdir -p -- "$1"`
mkdirScript = `exec mkdir -- "$1"`
chmodScript = `[ -e "$1" ] || exit 44
exec chmod "$2" -- "$1"`
removeScript = `[ -e "$1" ] || [ -L "$1" ] || exit 44
if [ -d "$1" ] && [ ! -L "$1" ]; then
if [ "$2" = 1 ]; then exec rm -rf -- "$1"; else exec rmdir -- "$1"; fi
fi
exec rm -f -- "$1"`
realPathScript = `case "$1" in
"~") p=$HOME ;;
"~/"*) p=$HOME/${1#"~/"} ;;
*) p=$1 ;;
esac
exec readlink -f -- "$p"`
)
func (f *files) Stat(ctx context.Context, p string) (sftpfs.Entry, error) {
res, err := f.c.script(ctx, nil, statScript, p)
if err != nil {
return sftpfs.Entry{}, err
}
if err := failure("stat "+p, res); err != nil {
return sftpfs.Entry{}, err
}
var size, mtime int64
var rawMode uint64
if _, err := fmt.Sscanf(strings.TrimSpace(string(res.Stdout)), "%d %x %d", &size, &rawMode, &mtime); err != nil {
return sftpfs.Entry{}, fmt.Errorf("stat %s: unreadable answer: %w", p, err)
}
mode := fileMode(uint32(rawMode))
return sftpfs.Entry{Name: path.Base(p), Path: p, Size: size, Mode: mode, ModTime: mtime, IsDir: mode.IsDir()}, nil
}
// fileMode decodes a raw st_mode by its bit fields: the format bits say what
// the file is, the low twelve are the permissions.
func fileMode(raw uint32) fs.FileMode {
mode := fs.FileMode(raw & 0o777)
if raw&0o4000 != 0 {
mode |= fs.ModeSetuid
}
if raw&0o2000 != 0 {
mode |= fs.ModeSetgid
}
if raw&0o1000 != 0 {
mode |= fs.ModeSticky
}
switch raw & 0o170000 {
case 0o040000:
mode |= fs.ModeDir
case 0o120000:
mode |= fs.ModeSymlink
case 0o010000:
mode |= fs.ModeNamedPipe
case 0o140000:
mode |= fs.ModeSocket
case 0o020000:
mode |= fs.ModeDevice | fs.ModeCharDevice
case 0o060000:
mode |= fs.ModeDevice
}
return mode
}
func (f *files) ReadFile(ctx context.Context, p string, maxSize int64) ([]byte, bool, sftpfs.Kind, error) {
if maxSize <= 0 {
maxSize = sftpfs.DefaultReadCap
}
res, err := f.c.script(ctx, nil, readScript, p, strconv.FormatInt(maxSize+1, 10))
if err != nil {
return nil, false, sftpfs.KindBinary, err
}
if err := failure("read "+p, res); err != nil {
return nil, false, sftpfs.KindBinary, err
}
data, truncated := res.Stdout, false
if int64(len(data)) > maxSize {
data, truncated = data[:maxSize], true
}
return data, truncated, sftpfs.DetectKind(data), nil
}
func (f *files) WriteFileAtomic(ctx context.Context, p string, data []byte, perm fs.FileMode) error {
res, err := f.c.script(ctx, bytes.NewReader(data), writeScript, p, strconv.FormatUint(uint64(perm.Perm()), 8))
if err != nil {
return err
}
return failure("write "+p, res)
}
func (f *files) MkdirAll(ctx context.Context, p string) error {
return f.simple(ctx, "mkdir "+p, mkdirAllScript, p)
}
func (f *files) MkdirExclusive(ctx context.Context, p string) error {
return f.simple(ctx, "mkdir "+p, mkdirScript, p)
}
func (f *files) Chmod(ctx context.Context, p string, perm fs.FileMode) error {
return f.simple(ctx, "chmod "+p, chmodScript, p, strconv.FormatUint(uint64(perm.Perm()), 8))
}
func (f *files) Remove(ctx context.Context, p string, recursive bool) error {
flag := "0"
if recursive {
flag = "1"
}
return f.simple(ctx, "remove "+p, removeScript, p, flag)
}
func (f *files) RealPath(ctx context.Context, p string) (string, error) {
res, err := f.c.script(ctx, nil, realPathScript, p)
if err != nil {
return "", err
}
if err := failure("realpath "+p, res); err != nil {
return "", err
}
return strings.TrimRight(string(res.Stdout), "\n"), nil
}
func (f *files) simple(ctx context.Context, op, body string, args ...string) error {
res, err := f.c.script(ctx, nil, body, args...)
if err != nil {
return err
}
return failure(op, res)
}
// dirsScript prints the directories under $1 (symlinks to directories
// included), each name closed by a NUL so no file name can end it early.
const dirsScript = `cd -- "$1" 2>/dev/null || exit 44
for f in ./* ./.[!.]* ./..?*; do
[ -d "$f" ] && printf '%s\0' "${f#./}"
done
exit 0`
// Dirs resolves dir (empty or ~ meaning the home directory) and lists the
// directories inside it.
func (c *Conn) Dirs(ctx context.Context, dir string) (string, []sftpfs.Entry, error) {
if strings.TrimSpace(dir) == "" {
dir = "~"
}
fsys := &files{c: c}
at, err := fsys.RealPath(ctx, dir)
if err != nil {
return "", nil, err
}
res, err := c.script(ctx, nil, dirsScript, at)
if err != nil {
return "", nil, err
}
if err := failure("list "+at, res); err != nil {
return "", nil, err
}
var out []sftpfs.Entry
for name := range strings.SplitSeq(string(res.Stdout), "\x00") {
if name == "" {
continue
}
out = append(out, sftpfs.Entry{Name: name, Path: path.Join(at, name), IsDir: true, Mode: fs.ModeDir})
}
return at, out, nil
}
@@ -0,0 +1,67 @@
//go:build windows
package wslconn
import (
"context"
"errors"
"os"
"testing"
"reasonix/internal/platform/wsl"
)
// REASONIX_LIVE_WSL names an installed WSL2 distribution to run the file layer in.
func TestFileLayerInARealDistribution(t *testing.T) {
name := os.Getenv("REASONIX_LIVE_WSL")
if name == "" {
t.Skip("set REASONIX_LIVE_WSL to an installed WSL2 distribution")
}
ctx := context.Background()
c, err := New(ctx, wsl.System(), wsl.SystemCommand(), name)
if err != nil {
t.Fatal(err)
}
res, err := c.Exec(ctx, "mktemp -d")
if err != nil || res.ExitCode != 0 {
t.Fatalf("mktemp = %+v, %v", res, err)
}
dir := string(res.Stdout[:len(res.Stdout)-1])
defer c.Exec(ctx, "rm -rf '"+dir+"'")
runFileScenario(t, c, dir)
runPathScenario(t, c)
}
// runPathScenario holds the translation to what a real distribution answers.
func runPathScenario(t *testing.T, c *Conn) {
t.Helper()
ctx := context.Background()
for in, want := range map[string]string{
`C:\Users\someone\a b`: "/mnt/c/Users/someone/a b",
`c:/Users/x`: "/mnt/c/Users/x",
`C:\a b\it's "q"\-dash\back`: "/mnt/c/a b/it's \"q\"/-dash/back",
`C:\a b\`: "/mnt/c/a b/",
`C:\dollar $HOME\` + "`z`" + `\ünï`: "/mnt/c/dollar $HOME/`z`/ünï",
`\\wsl.localhost\` + c.Distro() + `\home\dev`: "/home/dev",
} {
if got, err := c.ToLinux(ctx, in); err != nil || got != want {
t.Errorf("ToLinux(%q) = %q, %v; want %q", in, got, err, want)
}
}
for _, in := range []string{`\\wsl.localhost\Another\x`, `\\server\share\x`, `Z:\nodrive`, `relative\dir`, `/home/dev`, `C:`, ``} {
if _, err := c.ToLinux(ctx, in); !errors.Is(err, ErrUntranslatable) {
t.Errorf("ToLinux(%q) = %v, want ErrUntranslatable", in, err)
}
}
if got, err := c.ToWindows(ctx, "/mnt/c/Users"); err != nil || got != `C:\Users` {
t.Errorf("ToWindows(/mnt/c/Users) = %q, %v", got, err)
}
if got, err := c.ToWindows(ctx, "/home/dev"); err != nil || got != `\\wsl.localhost\`+c.Distro()+`\home\dev` {
t.Errorf("ToWindows(/home/dev) = %q, %v", got, err)
}
for _, in := range []string{"relative", "", `C:\x`} {
if _, err := c.ToWindows(ctx, in); !errors.Is(err, ErrUntranslatable) {
t.Errorf("ToWindows(%q) = %v, want ErrUntranslatable", in, err)
}
}
}
@@ -0,0 +1,46 @@
//go:build linux
package wslconn
import (
"bytes"
"context"
"errors"
"io"
"os/exec"
"testing"
"reasonix/internal/platform/wsl"
)
// localCommand answers wsl.exe's --exec by running the program here, so the
// scripts meet a real GNU userland without a distribution.
func localCommand(t *testing.T) wsl.Command {
t.Helper()
return func(ctx context.Context, stdin io.Reader, args ...string) (wsl.Result, error) {
for i, a := range args {
if a == "--exec" {
args = args[i+1:]
break
}
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var out, errb bytes.Buffer
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, &out, &errb
err := cmd.Run()
res := wsl.Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode, err = ee.ExitCode(), nil
}
return res, err
}
}
func TestFileLayerAgainstALocalPOSIXShell(t *testing.T) {
if err := exec.Command("stat", "-c", "%s", "/").Run(); err != nil {
t.Skip("needs GNU stat")
}
c := &Conn{distro: "local", run: localCommand(t)}
runFileScenario(t, c, t.TempDir())
}
+58
View File
@@ -0,0 +1,58 @@
package wslconn
import (
"context"
"errors"
"fmt"
"strings"
"reasonix/internal/platform/wsl"
)
// ErrUntranslatable: the path has no spelling on the other side, such as a
// drive that is not mounted or another distribution's share.
var ErrUntranslatable = errors.New("wslconn: path cannot be translated")
// ToLinux turns an absolute Windows path, a drive path or this distribution's
// own \\wsl.localhost share, into the path the distribution sees. The
// distribution's wslpath does it, because only it knows where drives are mounted.
func (c *Conn) ToLinux(ctx context.Context, p string) (string, error) {
if !wsl.IsWindowsPath(p) {
return "", fmt.Errorf("%w: %q is not an absolute Windows path", ErrUntranslatable, p)
}
out, err := c.wslpath(ctx, "-u", p)
if err != nil {
return "", err
}
if !strings.HasPrefix(out, "/") {
return "", fmt.Errorf("%w: %q has no absolute form in %q", ErrUntranslatable, p, c.distro)
}
return out, nil
}
// ToWindows is the reverse: an absolute Linux path as Windows spells it, a
// drive path where it is under a mount and this distribution's share otherwise.
func (c *Conn) ToWindows(ctx context.Context, p string) (string, error) {
if !strings.HasPrefix(p, "/") {
return "", fmt.Errorf("%w: %q is not an absolute Linux path", ErrUntranslatable, p)
}
out, err := c.wslpath(ctx, "-w", p)
if err != nil {
return "", err
}
if !wsl.IsWindowsPath(out) {
return "", fmt.Errorf("%w: %q has no Windows form", ErrUntranslatable, p)
}
return out, nil
}
func (c *Conn) wslpath(ctx context.Context, flag, p string) (string, error) {
res, err := c.Run(ctx, nil, "wslpath", flag, p)
if err != nil {
return "", err
}
if res.ExitCode != 0 {
return "", fmt.Errorf("%w: %q", ErrUntranslatable, p)
}
return strings.TrimRight(string(res.Stdout), "\r\n"), nil
}
@@ -0,0 +1,49 @@
package wslconn
import (
"context"
"errors"
"io"
"testing"
"reasonix/internal/platform/wsl"
)
// answers is a distribution whose wslpath says what the table says.
func answers(table map[string]wsl.Result) *Conn {
return &Conn{distro: "Ubuntu", run: func(_ context.Context, _ io.Reader, args ...string) (wsl.Result, error) {
// -d Ubuntu --exec wslpath <flag> <path>
return table[args[5]], nil
}}
}
func TestATranslationIsTheDistributionsAnswerOrARefusal(t *testing.T) {
c := answers(map[string]wsl.Result{
`C:\work\app`: {Stdout: []byte("/mnt/c/work/app\n")},
`Z:\none`: {ExitCode: 1},
`C:\odd`: {Stdout: []byte("C:\\odd\n")},
"/mnt/c/work": {Stdout: []byte("C:\\work\r\n")},
"/home/dev": {Stdout: []byte("\\\\wsl.localhost\\Ubuntu\\home\\dev\n")},
"/odd": {Stdout: []byte("/odd\n")},
})
ctx := context.Background()
if got, err := c.ToLinux(ctx, `C:\work\app`); err != nil || got != "/mnt/c/work/app" {
t.Fatalf("ToLinux = %q, %v", got, err)
}
for _, in := range []string{`Z:\none`, `C:\odd`, `/home/dev`, `rel`, ``} {
if _, err := c.ToLinux(ctx, in); !errors.Is(err, ErrUntranslatable) {
t.Errorf("ToLinux(%q) = %v, want ErrUntranslatable", in, err)
}
}
if got, err := c.ToWindows(ctx, "/mnt/c/work"); err != nil || got != `C:\work` {
t.Fatalf("ToWindows = %q, %v", got, err)
}
if got, err := c.ToWindows(ctx, "/home/dev"); err != nil || got != `\\wsl.localhost\Ubuntu\home\dev` {
t.Fatalf("ToWindows share = %q, %v", got, err)
}
for _, in := range []string{"/odd", "rel", `C:\x`} {
if _, err := c.ToWindows(ctx, in); !errors.Is(err, ErrUntranslatable) {
t.Errorf("ToWindows(%q) = %v, want ErrUntranslatable", in, err)
}
}
}
@@ -0,0 +1,107 @@
//go:build linux || windows
package wslconn
import (
"bytes"
"context"
"errors"
"io/fs"
"path"
"strings"
"testing"
"reasonix/internal/platform/remote/sftpfs"
)
// runFileScenario drives the file layer against a real POSIX shell: the local
// one in CI, a distribution in the live test. dir is a scratch directory there.
func runFileScenario(t *testing.T, c *Conn, dir string) {
t.Helper()
ctx := context.Background()
fsys, _ := c.Files()
awkward := path.Join(dir, `a b "q" 'x' $HOME `+"`z`"+` ünï back\slash trailing\ -dash`)
if err := fsys.MkdirAll(ctx, awkward+"/deep/er"); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := fsys.MkdirExclusive(ctx, awkward+"/deep"); err == nil {
t.Fatal("MkdirExclusive on an existing directory succeeded")
}
if err := fsys.MkdirExclusive(ctx, awkward+"/lock"); err != nil {
t.Fatalf("MkdirExclusive: %v", err)
}
if err := fsys.MkdirAll(ctx, awkward+"/-dash"); err != nil {
t.Fatalf("MkdirAll of a component that starts with a dash: %v", err)
}
file := awkward + "/-dash/state.json"
want := []byte("{\"port\": 4242}\n")
if err := fsys.WriteFileAtomic(ctx, file, want, 0o600); err != nil {
t.Fatalf("WriteFileAtomic: %v", err)
}
if err := fsys.WriteFileAtomic(ctx, file, append(want, 'x'), 0o600); err != nil {
t.Fatalf("overwrite: %v", err)
}
got, truncated, kind, err := fsys.ReadFile(ctx, file, 0)
if err != nil || truncated || kind != sftpfs.KindText || !bytes.Equal(got, append(want, 'x')) {
t.Fatalf("ReadFile = %q, %v, %v, %v", got, truncated, kind, err)
}
if head, truncated, _, err := fsys.ReadFile(ctx, file, 4); err != nil || !truncated || string(head) != "{\"po" {
t.Fatalf("a capped read = %q, %v, %v", head, truncated, err)
}
en, err := fsys.Stat(ctx, file)
if err != nil || en.IsDir || en.Size != int64(len(want)+1) || en.Mode.Perm() != 0o600 || en.ModTime == 0 {
t.Fatalf("Stat file = %+v, %v", en, err)
}
if en, err := fsys.Stat(ctx, awkward); err != nil || !en.IsDir {
t.Fatalf("Stat dir = %+v, %v", en, err)
}
if _, _, _, err := fsys.ReadFile(ctx, awkward+"/deep", 0); err == nil || errors.Is(err, fs.ErrNotExist) {
t.Fatalf("reading a directory = %v, want a failure that is not 'missing'", err)
}
if err := fsys.Chmod(ctx, file, 0o640); err != nil {
t.Fatalf("Chmod: %v", err)
}
if en, _ := fsys.Stat(ctx, file); en.Mode.Perm() != 0o640 {
t.Fatalf("mode after chmod = %v", en.Mode)
}
for name, call := range map[string]func() error{
"stat": func() error { _, err := fsys.Stat(ctx, awkward+"/none"); return err },
"read": func() error { _, _, _, err := fsys.ReadFile(ctx, awkward+"/none", 0); return err },
"chmod": func() error { return fsys.Chmod(ctx, awkward+"/none", 0o600) },
"remove": func() error { return fsys.Remove(ctx, awkward+"/none", false) },
} {
if err := call(); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("%s of a missing path = %v, want fs.ErrNotExist", name, err)
}
}
real, err := fsys.RealPath(ctx, awkward+"/deep/../-dash/state.json")
if err != nil || !strings.HasSuffix(real, "/-dash/state.json") || strings.Contains(real, "..") {
t.Fatalf("RealPath = %q, %v", real, err)
}
if home, err := fsys.RealPath(ctx, "~"); err != nil || !strings.HasPrefix(home, "/") {
t.Fatalf("RealPath(~) = %q, %v", home, err)
}
if err := fsys.Remove(ctx, awkward+"/deep", false); err == nil {
t.Fatal("a non-recursive remove of a non-empty directory succeeded")
}
if err := fsys.Remove(ctx, file, false); err != nil {
t.Fatalf("Remove file: %v", err)
}
if err := fsys.Remove(ctx, awkward, true); err != nil {
t.Fatalf("Remove tree: %v", err)
}
if _, err := fsys.Stat(ctx, awkward); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("the removed tree is still there: %v", err)
}
res, err := c.Exec(ctx, "printf '%s' \"$((6*7))\"; echo err >&2; exit 3")
if err != nil || string(res.Stdout) != "42" || strings.TrimSpace(string(res.Stderr)) != "err" || res.ExitCode != 3 {
t.Fatalf("Exec = %+v, %v", res, err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package wsl
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/base/proc"
)
// maxCapture bounds what one command may hand back on each stream. A file the
// caller wants whole is read in pieces by the layer above, not by raising it.
const maxCapture = 8 << 20
// Result is what a command in a distribution wrote and how it ended. A command
// that ran and failed is a Result with a non-zero ExitCode, not an error: the
// error return is for a command that could not be run at all.
type Result struct {
Stdout, Stderr []byte
ExitCode int
}
// Command runs wsl.exe with args, feeding stdin when it is not nil.
type Command func(ctx context.Context, stdin io.Reader, args ...string) (Result, error)
// SystemCommand runs the wsl.exe in System32, so a PATH entry cannot stand in for it.
func SystemCommand() Command {
return func(ctx context.Context, stdin io.Reader, args ...string) (Result, error) {
dir := systemDirectory()
if runtime.GOOS != "windows" || dir == "" {
return Result{}, ErrUnavailable
}
exe := filepath.Join(dir, "wsl.exe")
if _, err := os.Stat(exe); err != nil {
return Result{}, fmt.Errorf("%w: %w", ErrUnavailable, err)
}
cmd := exec.CommandContext(ctx, exe, args...)
proc.HideWindow(cmd)
var out, errb bytes.Buffer
cmd.Stdin = stdin
cmd.Stdout = &limited{w: &out, left: maxCapture}
cmd.Stderr = &limited{w: &errb, left: maxCapture}
err := cmd.Run()
res := Result{Stdout: out.Bytes(), Stderr: errb.Bytes()}
var ee *exec.ExitError
if errors.As(err, &ee) {
res.ExitCode = ee.ExitCode()
return res, nil
}
return res, err
}
}
// ExecArgs is the wsl.exe argument list that runs argv inside a distribution
// with no shell between wsl.exe and the program. The distribution name is its
// own argument, so it cannot become an option.
func ExecArgs(distro string, argv ...string) []string {
return append([]string{"-d", distro, "--exec"}, argv...)
}
// IsWindowsPath reports whether p is spelled as an absolute Windows path: a
// drive letter and a colon, or a UNC prefix. It reads the shape only, so a
// Linux path with a colon in a later component is not mistaken for one.
func IsWindowsPath(p string) bool {
if strings.HasPrefix(p, `\\`) {
return true
}
if len(p) < 2 || p[1] != ':' {
return false
}
c := p[0] | 0x20
return c >= 'a' && c <= 'z'
}
+11
View File
@@ -116,3 +116,14 @@ func TestSystemIsUnavailableOffWindows(t *testing.T) {
t.Fatalf("List = %v", err)
}
}
func TestAWindowsPathIsRecognizedByItsShape(t *testing.T) {
for p, want := range map[string]bool{
`C:\Users`: true, `c:/x`: true, `Z:`: true, `\\wsl.localhost\Ubuntu\x`: true, `\\server\share`: true,
`/mnt/c/x`: false, `/home/a:b`: false, `relative\dir`: false, `1:\x`: false, ``: false, `C`: false, `\x`: false,
} {
if got := IsWindowsPath(p); got != want {
t.Errorf("IsWindowsPath(%q) = %v, want %v", p, got, want)
}
}
}