commit 6b52e83bdcb7a4b2134c45ddf3f00c72cc36c797 Author: bsncubed Date: Wed Jul 29 23:21:21 2026 +1000 Initial commit: haul — desktop SFTP client A two-pane local/remote file browser over SSH, split out of dial so file transfer lives in its own tool. The two share ~/.ssh/config as their base. - internal/sshconf: ~/.ssh/config parsing, for the saved-host list. - internal/sshx: dialling and auth — agent, keys, password, host-key verification, ProxyJump. - internal/vfs: the FS interface both sides are driven through, implemented over the local disk and over SFTP. - internal/xfer: recursive copy engine with progress reporting, cancellation, conflict policy, and resume of partial transfers. - internal/ui: the Fyne GUI — connect window (saved hosts plus quick connect) and the two-pane browser session. Build with `make build`; the Makefile locates the Go toolchain at ~/.local/go, which is deliberately kept off PATH. Cross-compiling needs a per-target C toolchain because Fyne is cgo, so `make dist` builds the native binary only — the alternatives are documented in the Makefile. Co-Authored-By: Claude Opus 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4febd3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..41c6a96 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +VERSION ?= 0.1.0 +# No -X main.version here: cmd/haul has no version var yet, and a -X against a +# symbol that doesn't exist is silently a no-op rather than an error. +LDFLAGS := -s -w + +# Go is installed at ~/.local/go on this box and deliberately kept off PATH, so +# prefer that toolchain and fall back to whatever `go` is in PATH elsewhere. +GO ?= $(shell test -x $(HOME)/.local/go/bin/go && echo $(HOME)/.local/go/bin/go || echo go) + +.PHONY: build install test vet check run run-xvfb dist clean + +build: ## Build a native binary into ./bin/haul + $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/haul ./cmd/haul + +install: ## Install haul into $GOBIN / $GOPATH/bin + $(GO) install -trimpath -ldflags "$(LDFLAGS)" ./cmd/haul + +test: ## Run tests + $(GO) test ./... + +vet: ## Run go vet + $(GO) vet ./... + +# GOOS=js typechecks every package, Fyne API usage included, without needing the +# cgo/X11 toolchain. It proves nothing about layout or runtime behaviour. +check: ## Typecheck the whole tree without cgo + GOOS=js GOARCH=wasm $(GO) build ./... + +run: build ## Build and run (needs a DISPLAY) + ./bin/haul + +# This box is headless. Xvfb gives haul a display to render into; software GL +# because there's no GPU. +# +# Cleanup is a trap on a PID held in a shell variable, never a pidfile and never +# pkill. A pidfile survives Ctrl-C and goes stale, and PIDs get recycled, so a +# later `kill $(cat .xvfb.pid)` can hit an unrelated process. `pkill -f Xvfb` is +# worse still: the pattern also matches make's own command line, and this box +# runs inside tmux, where a stray match can take out the session. +run-xvfb: build ## Build and run against a virtual X display + @if [ -e /tmp/.X99-lock ]; then \ + echo "display :99 is already in use — close that first"; exit 1; \ + fi; \ + Xvfb :99 -screen 0 1500x900x24 >/dev/null 2>&1 & \ + xvfb_pid=$$!; \ + trap 'kill $$xvfb_pid 2>/dev/null || true' INT TERM EXIT; \ + sleep 1; \ + DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 ./bin/haul || true + +# haul is cgo (Fyne needs OpenGL + X11), so `GOOS=windows go build` does NOT +# work the way it does for dial. Cross-targets need a matching C toolchain: +# linux/arm64 -> apt install gcc-aarch64-linux-gnu +# windows/amd64-> apt install gcc-mingw-w64 +# darwin/* -> a Mac, or osxcross +# Docker is present, so `fyne-cross` is the practical route for the others: +# go install github.com/fyne-io/fyne-cross@latest +# fyne-cross windows -arch=amd64 +dist: ## Build the release binary for this host into ./dist + @mkdir -p dist + $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o dist/haul-linux-amd64 ./cmd/haul + @echo "built dist/haul-linux-amd64 (see the Makefile for cross-compiling)" + +clean: ## Remove build artifacts + rm -rf bin dist diff --git a/cmd/haul/main.go b/cmd/haul/main.go new file mode 100644 index 0000000..766d2ba --- /dev/null +++ b/cmd/haul/main.go @@ -0,0 +1,21 @@ +// Command haul is a desktop SFTP client: a two-pane local/remote file browser +// over SSH, with ~/.ssh/config as its host list and quick connect for anything +// that isn't in it. +// +// haul was split out of `dial` (the SSH host picker) so file transfer lives in +// its own tool. The two share the same ~/.ssh/config as their base. +package main + +import ( + "fmt" + "os" + + "gitea.apointless.space/bsncubed/haul/internal/ui" +) + +func main() { + if err := ui.Run(); err != nil { + fmt.Fprintln(os.Stderr, "haul:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..488fc14 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module gitea.apointless.space/bsncubed/haul + +go 1.24 + +require ( + fyne.io/fyne/v2 v2.6.1 + github.com/pkg/sftp v1.13.9 + golang.org/x/crypto v0.36.0 +) + +require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fyne-io/gl-js v0.1.0 // indirect + github.com/fyne-io/glfw-js v0.2.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8d9e3e5 --- /dev/null +++ b/go.sum @@ -0,0 +1,158 @@ +fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is= +fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= +github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= +github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc= +github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/sshconf/sshconf.go b/internal/sshconf/sshconf.go new file mode 100644 index 0000000..65a967b --- /dev/null +++ b/internal/sshconf/sshconf.go @@ -0,0 +1,264 @@ +// Package sshconf parses ~/.ssh/config (following Include directives) into a +// flat list of connectable Host entries. It is strictly read-only: nothing in +// this package ever writes to the ssh config tree. +// +// A small hand-written parser is used instead of a third-party library so that +// two things the ssh_config format does not natively support are available: +// - the "# tag: a, b" comment convention placed above a Host block, and +// - full control over Include expansion order for stable, fast startup. +package sshconf + +import ( + "bufio" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +// Host is a single connectable ssh alias with the preview fields dial surfaces. +// It intentionally holds only what the picker needs; connecting is done by +// exec'ing `ssh ` so the real ssh client re-resolves everything. +type Host struct { + Alias string + HostName string + User string + Port string + ProxyJump string + Tags []string + Source string // config file this entry was declared in +} + +// maxIncludeDepth guards against pathological or cyclic Include chains. +const maxIncludeDepth = 16 + +// DefaultConfigPath returns ~/.ssh/config for the current user. +func DefaultConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ssh", "config"), nil +} + +// Parse reads the config at path, follows Include directives, and returns the +// concrete hosts in declaration order. A missing top-level config is not an +// error: it yields an empty list so a fresh machine still launches cleanly. +func Parse(path string) ([]Host, error) { + sshDir := filepath.Dir(path) + p := &parser{sshDir: sshDir, seen: map[string]bool{}} + if err := p.parseFile(path, 0); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + out := make([]Host, len(p.hosts)) + for i, h := range p.hosts { + out[i] = *h + } + return out, nil +} + +type parser struct { + sshDir string + seen map[string]bool // include cycle guard, by absolute path + hosts []*Host // pointers so block updates stay valid across appends +} + +func (p *parser) parseFile(path string, depth int) error { + if depth > maxIncludeDepth { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + if p.seen[abs] { + return nil // already parsed; avoid include cycles + } + p.seen[abs] = true + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + return p.parseReader(f, path, depth) +} + +func (p *parser) parseReader(r io.Reader, source string, depth int) error { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var pendingTags []string // tags from a "# tag:" comment awaiting the next Host + var block []*Host // all aliases in the current Host block + + // applyKV sets a keyword/value pair on every host in the current block. + applyKV := func(keyword, value string) { + for _, h := range block { + switch strings.ToLower(keyword) { + case "hostname": + h.HostName = value + case "user": + h.User = value + case "port": + h.Port = value + case "proxyjump": + h.ProxyJump = value + } + } + } + + for sc.Scan() { + raw := sc.Text() + line := strings.TrimSpace(raw) + if line == "" { + continue + } + + if strings.HasPrefix(line, "#") { + if tags, ok := parseTagComment(line); ok { + pendingTags = append(pendingTags, tags...) + } + continue + } + + keyword, value := splitKeyword(line) + if keyword == "" { + continue + } + + switch strings.ToLower(keyword) { + case "host": + block = nil + for _, alias := range strings.Fields(value) { + if isPattern(alias) { + continue // wildcard/negated patterns aren't connect targets + } + h := &Host{Alias: alias, Source: source, Tags: append([]string(nil), pendingTags...)} + p.hosts = append(p.hosts, h) + block = append(block, h) + } + pendingTags = nil + + case "include": + // Include is resolved relative to ~/.ssh and may contain globs. + for _, pat := range strings.Fields(value) { + for _, inc := range p.expandInclude(pat) { + _ = p.parseFile(inc, depth+1) + } + } + pendingTags = nil + + case "match": + // Match blocks are conditional; dial does not evaluate conditions. + // End the current block so stray keywords don't attach to a host. + block = nil + pendingTags = nil + + default: + if len(block) > 0 { + applyKV(keyword, value) + } + } + } + return sc.Err() +} + +// expandInclude resolves an Include pattern to concrete file paths. Relative +// patterns are anchored at ~/.ssh, matching OpenSSH behaviour. +func (p *parser) expandInclude(pattern string) []string { + pattern = expandTilde(pattern) + if !filepath.IsAbs(pattern) { + pattern = filepath.Join(p.sshDir, pattern) + } + matches, err := filepath.Glob(pattern) + if err != nil || len(matches) == 0 { + return nil + } + sort.Strings(matches) // deterministic order across machines + // Filter out directories; only include regular files. + var files []string + for _, m := range matches { + if fi, err := os.Stat(m); err == nil && !fi.IsDir() { + files = append(files, m) + } + } + return files +} + +// parseTagComment recognises the "# tag: prod, riedel" convention and returns +// the individual tags. It is lenient about spacing and the tag/tags spelling. +func parseTagComment(line string) ([]string, bool) { + body := strings.TrimSpace(strings.TrimPrefix(line, "#")) + lower := strings.ToLower(body) + var rest string + switch { + case strings.HasPrefix(lower, "tags:"): + rest = body[len("tags:"):] + case strings.HasPrefix(lower, "tag:"): + rest = body[len("tag:"):] + default: + return nil, false + } + var tags []string + for _, t := range strings.Split(rest, ",") { + if t = strings.TrimSpace(t); t != "" { + tags = append(tags, t) + } + } + return tags, len(tags) > 0 +} + +// splitKeyword splits an ssh_config line into keyword and value. ssh_config +// allows "Key Value" and "Key = Value"; both are handled here. +func splitKeyword(line string) (string, string) { + // Strip trailing inline comments only when clearly separated. + if i := strings.Index(line, " #"); i >= 0 { + // keep '#' inside values rare; safe to drop trailing comment + line = strings.TrimSpace(line[:i]) + } + if eq := strings.IndexByte(line, '='); eq >= 0 && !strings.ContainsAny(line[:eq], " \t") { + return strings.TrimSpace(line[:eq]), unquote(strings.TrimSpace(line[eq+1:])) + } + fields := strings.Fields(line) + if len(fields) == 0 { + return "", "" + } + if len(fields) == 1 { + return fields[0], "" + } + // Support "Key = Value" where '=' is its own token. + if fields[1] == "=" { + return fields[0], unquote(strings.TrimSpace(strings.Join(fields[2:], " "))) + } + return fields[0], unquote(strings.TrimSpace(strings.Join(fields[1:], " "))) +} + +func unquote(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +} + +// isPattern reports whether an alias is a wildcard/negation pattern rather than +// a concrete connectable host. +func isPattern(alias string) bool { + return strings.ContainsAny(alias, "*?!") +} + +func expandTilde(path string) string { + if path == "~" || strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + if path == "~" { + return home + } + return filepath.Join(home, path[2:]) + } + } + return path +} diff --git a/internal/sshconf/sshconf_test.go b/internal/sshconf/sshconf_test.go new file mode 100644 index 0000000..ff00043 --- /dev/null +++ b/internal/sshconf/sshconf_test.go @@ -0,0 +1,106 @@ +package sshconf + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseWithIncludeAndTags(t *testing.T) { + dir := t.TempDir() + incDir := filepath.Join(dir, "conf.d") + if err := os.MkdirAll(incDir, 0o755); err != nil { + t.Fatal(err) + } + + main := `# global defaults +Host * + ServerAliveInterval 60 + +# tag: prod, riedel +Host web1 web2 + HostName 10.0.0.5 + User deploy + Port 2222 + ProxyJump bastion + +Include conf.d/*.conf + +Host bastion + HostName bastion.example.com + User admin +` + inc := `# tag: staging +Host stage + HostName stage.example.com + User dev = ignored +Host = eqform + HostName eq.example.com +` + if err := os.WriteFile(filepath.Join(dir, "config"), []byte(main), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(incDir, "a.conf"), []byte(inc), 0o644); err != nil { + t.Fatal(err) + } + + hosts, err := Parse(filepath.Join(dir, "config")) + if err != nil { + t.Fatal(err) + } + + byAlias := map[string]Host{} + for _, h := range hosts { + byAlias[h.Alias] = h + } + + // Wildcard "Host *" must not appear as a connectable target. + if _, ok := byAlias["*"]; ok { + t.Error("wildcard host * should be skipped") + } + + // Multiple aliases in one block share settings and tags. + for _, a := range []string{"web1", "web2"} { + h, ok := byAlias[a] + if !ok { + t.Fatalf("missing host %s", a) + } + if h.HostName != "10.0.0.5" || h.User != "deploy" || h.Port != "2222" || h.ProxyJump != "bastion" { + t.Errorf("%s wrong fields: %+v", a, h) + } + if len(h.Tags) != 2 || h.Tags[0] != "prod" || h.Tags[1] != "riedel" { + t.Errorf("%s wrong tags: %v", a, h.Tags) + } + } + + // Included file's host and its tag. + stage, ok := byAlias["stage"] + if !ok { + t.Fatal("included host 'stage' missing") + } + if len(stage.Tags) != 1 || stage.Tags[0] != "staging" { + t.Errorf("stage tags wrong: %v", stage.Tags) + } + + // "Host = eqform" equals-form should parse the alias. + if _, ok := byAlias["eqform"]; !ok { + t.Error("equals-form Host 'eqform' missing") + } + + // Ordering: web1 before included stage before bastion. + order := []string{} + for _, h := range hosts { + order = append(order, h.Alias) + } + t.Logf("order: %v", order) +} + +func TestParseMissingConfig(t *testing.T) { + hosts, err := Parse(filepath.Join(t.TempDir(), "does-not-exist")) + if err != nil { + t.Fatalf("missing config should not error: %v", err) + } + if hosts != nil { + t.Errorf("expected nil hosts, got %v", hosts) + } +} diff --git a/internal/sshx/live_test.go b/internal/sshx/live_test.go new file mode 100644 index 0000000..98767e7 --- /dev/null +++ b/internal/sshx/live_test.go @@ -0,0 +1,68 @@ +package sshx + +import ( + "os" + "testing" + + "github.com/pkg/sftp" +) + +// TestLiveConnect exercises the real auth + SFTP path against a live host. +// Gated on DIAL_LIVE_HOST so the normal test suite never needs a server. +// DIAL_LIVE_HOST=192.168.192.201 go test ./internal/sshx -run TestLiveConnect -v +func TestLiveConnect(t *testing.T) { + alias := os.Getenv("DIAL_LIVE_HOST") + if alias == "" { + t.Skip("set DIAL_LIVE_HOST to run the live connection test") + } + opts := Options{Logf: func(format string, args ...any) { t.Logf(format, args...) }} + client, err := Connect(alias, opts) + if err != nil { + t.Fatalf("Connect(%q): %v", alias, err) + } + defer client.Close() + + sf, err := sftp.NewClient(client) + if err != nil { + t.Fatalf("sftp.NewClient: %v", err) + } + defer sf.Close() + + entries, err := sf.ReadDir(".") + if err != nil { + t.Fatalf("ReadDir(.): %v", err) + } + for _, e := range entries { + t.Logf(" %s\t%d bytes", e.Name(), e.Size()) + } + t.Logf("SFTP OK: %d entries in remote home", len(entries)) + + // Round-trip: write a file, read it back, verify contents, clean up. + const remote = "dial-live-test.txt" + want := []byte("dial round-trip @ live test\n") + w, err := sf.Create(remote) + if err != nil { + t.Fatalf("Create(%s): %v", remote, err) + } + if _, err := w.Write(want); err != nil { + t.Fatalf("Write: %v", err) + } + w.Close() + + r, err := sf.Open(remote) + if err != nil { + t.Fatalf("Open(%s): %v", remote, err) + } + got := make([]byte, len(want)) + if _, err := r.Read(got); err != nil { + t.Fatalf("Read: %v", err) + } + r.Close() + if string(got) != string(want) { + t.Fatalf("round-trip mismatch: got %q want %q", got, want) + } + if err := sf.Remove(remote); err != nil { + t.Fatalf("Remove(%s): %v", remote, err) + } + t.Logf("round-trip OK: wrote %d bytes, read back identical, removed", len(want)) +} diff --git a/internal/sshx/sshx.go b/internal/sshx/sshx.go new file mode 100644 index 0000000..db3ada3 --- /dev/null +++ b/internal/sshx/sshx.go @@ -0,0 +1,475 @@ +// Package sshx establishes the in-process SSH connection the SFTP browser runs +// over. haul is a GUI, so it never hands the terminal to the system ssh client; +// it opens the transport itself. +// +// There are two ways in. Connect resolves a ~/.ssh/config alias by asking the +// system ssh client via `ssh -G `, which prints the fully resolved +// effective configuration (HostName, User, Port, IdentityFile, ProxyJump, +// known-hosts files, …) — so ssh_config semantics are never re-implemented here. +// ConnectParams takes an explicit target instead, for quick connect. Both then +// authenticate with the ssh-agent, any identity files, and optionally a +// password; verify the host key against known_hosts; and dial directly or +// through a ProxyJump chain. +package sshx + +import ( + "bufio" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" +) + +// Params is the subset of resolved ssh_config that dial needs to connect. +type Params struct { + Alias string + HostName string + User string + Port string + IdentityFiles []string + ProxyJump string // "" or an alias/host (possibly comma-separated) + KnownHostsFile []string + StrictHostKey string // "yes" | "no" | "accept-new" | "ask" + IdentityAgent string // socket path, or "" / "none" + IdentitiesOnly bool // only offer the configured identity files +} + +// Options tunes a connection attempt. Every callback may block on user input: +// they are invoked from the connecting goroutine, never from the UI thread. +type Options struct { + // Passphrase is called to unlock an encrypted identity file that the agent + // doesn't already hold. It may return an empty slice to skip the key. + Passphrase func(keyfile string) ([]byte, error) + // Password, if set, enables password (and keyboard-interactive) + // authentication as a fallback after any keys have been offered. It is the + // quick-connect path: a host typed into the GUI has no configured identity. + Password func(user, host string) ([]byte, error) + // AcceptHostKey, if set, is consulted when the server's key is not in + // known_hosts, and the key is recorded when it returns true. Without it an + // unknown host is refused outright. A *changed* key is always refused and + // never reaches this callback. + AcceptHostKey func(host string, key ssh.PublicKey) (bool, error) + // Logf, if set, receives human-readable diagnostics about which auth methods + // were assembled — printed by the caller when a connection fails. + Logf func(format string, args ...any) +} + +func (o Options) logf(format string, args ...any) { + if o.Logf != nil { + o.Logf(format, args...) + } +} + +// Addr returns host:port for dialing. +func (p Params) Addr() string { + port := p.Port + if port == "" { + port = "22" + } + return net.JoinHostPort(p.HostName, port) +} + +const dialTimeout = 20 * time.Second + +// Resolve runs `ssh -G alias` and parses the effective configuration. +func Resolve(alias string) (Params, error) { + out, err := exec.Command("ssh", "-G", alias).Output() + if err != nil { + return Params{}, fmt.Errorf("resolving ssh config for %q (is ssh installed?): %w", alias, err) + } + return parseSSHG(alias, string(out)), nil +} + +// parseSSHG parses `ssh -G` key/value output. Keys are case-insensitive and +// some (identityfile, userknownhostsfile) may repeat. +func parseSSHG(alias, out string) Params { + p := Params{Alias: alias} + sc := bufio.NewScanner(strings.NewReader(out)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + key, val, ok := strings.Cut(line, " ") + if !ok { + continue + } + val = strings.TrimSpace(val) + switch strings.ToLower(key) { + case "hostname": + p.HostName = val + case "user": + p.User = val + case "port": + p.Port = val + case "identityfile": + p.IdentityFiles = append(p.IdentityFiles, expandTilde(val)) + case "proxyjump": + if val != "none" { + p.ProxyJump = val + } + case "userknownhostsfile": + for _, f := range strings.Fields(val) { + p.KnownHostsFile = append(p.KnownHostsFile, expandTilde(f)) + } + case "stricthostkeychecking": + p.StrictHostKey = val + case "identityagent": + p.IdentityAgent = expandTilde(val) + case "identitiesonly": + p.IdentitiesOnly = strings.EqualFold(val, "yes") + } + } + if p.HostName == "" { + p.HostName = alias + } + return p +} + +// Connect opens an SSH client to the given alias, following a ProxyJump chain if +// configured. The caller is responsible for Close. +func Connect(alias string, opts Options) (*ssh.Client, error) { + p, err := Resolve(alias) + if err != nil { + return nil, err + } + return connect(p, 0, opts) +} + +// ConnectParams opens an SSH client to an explicitly specified target without +// consulting ssh_config — haul's quick-connect path, where the user typed a +// host, port and username into the GUI. Auth still uses the agent and any +// IdentityFiles set on p, falling back to Options.Password. +func ConnectParams(p Params, opts Options) (*ssh.Client, error) { + if p.HostName == "" { + return nil, errors.New("no host given") + } + if p.Alias == "" { + p.Alias = p.HostName + } + return connect(p, 0, opts) +} + +const maxJumps = 8 + +func connect(p Params, depth int, opts Options) (*ssh.Client, error) { + if depth > maxJumps { + return nil, errors.New("ProxyJump chain too deep") + } + cfg, err := clientConfig(p, opts) + if err != nil { + return nil, err + } + + if p.ProxyJump == "" { + client, err := ssh.Dial("tcp", p.Addr(), cfg) + if err != nil { + return nil, fmt.Errorf("connecting to %s: %w", p.Alias, err) + } + return client, nil + } + + // Dial through the jump host. Only the first hop of a comma-separated list + // is resolved here; each hop recurses, so a chain still works. + jumpAlias := strings.Split(p.ProxyJump, ",")[0] + jp, err := Resolve(jumpAlias) + if err != nil { + return nil, err + } + jumpClient, err := connect(jp, depth+1, opts) + if err != nil { + return nil, fmt.Errorf("connecting via jump host %s: %w", jumpAlias, err) + } + conn, err := jumpClient.Dial("tcp", p.Addr()) + if err != nil { + jumpClient.Close() + return nil, fmt.Errorf("dialing %s through %s: %w", p.Alias, jumpAlias, err) + } + ncc, chans, reqs, err := ssh.NewClientConn(conn, p.Addr(), cfg) + if err != nil { + jumpClient.Close() + return nil, fmt.Errorf("ssh handshake with %s: %w", p.Alias, err) + } + return ssh.NewClient(ncc, chans, reqs), nil +} + +func clientConfig(p Params, opts Options) (*ssh.ClientConfig, error) { + user := p.User + if user == "" { + if u := os.Getenv("USER"); u != "" { + user = u + } + } + auth, err := authMethods(p, opts) + if err != nil { + return nil, err + } + hkCallback, err := hostKeyCallback(p, opts) + if err != nil { + return nil, err + } + return &ssh.ClientConfig{ + User: user, + Auth: auth, + HostKeyCallback: hkCallback, + Timeout: dialTimeout, + }, nil +} + +// authMethods builds the public-key auth, mirroring how the system ssh client +// chooses keys so it stays under the server's MaxAuthTries limit: +// +// - It offers only the keys the config's IdentityFile lines point to, not +// every key in the agent. Offering the whole agent plus all default +// identity files can exceed MaxAuthTries (typically 6) and the server drops +// the connection before the right key is tried. +// - For each configured identity, it prefers a matching signer already held +// by the agent (so passphrase-protected keys loaded via ssh-add work without +// re-prompting), then falls back to loading the key file (prompting for a +// passphrase if the key is encrypted and not in the agent). +// - If no configured identity resolves, it falls back to offering the agent's +// own keys so agent-only setups still work. +func authMethods(p Params, opts Options) ([]ssh.AuthMethod, error) { + agentSigners := dialAgent(p, opts) + byPub := map[string]ssh.Signer{} + for _, s := range agentSigners { + byPub[string(s.PublicKey().Marshal())] = s + } + opts.logf("ssh-agent: %d key(s) available", len(agentSigners)) + + var ordered []ssh.Signer + seen := map[string]bool{} + add := func(s ssh.Signer) { + k := string(s.PublicKey().Marshal()) + if !seen[k] { + seen[k] = true + ordered = append(ordered, s) + } + } + + for _, f := range p.IdentityFiles { + // Prefer the agent's copy of this identity, matched by its public key. + if pub, err := readPublicKey(f + ".pub"); err == nil { + if s, ok := byPub[string(pub.Marshal())]; ok { + opts.logf("identity %s: using agent-held key", f) + add(s) + continue + } + } + b, err := os.ReadFile(f) + if err != nil { + continue // identity file not present; skip quietly + } + signer, err := ssh.ParsePrivateKey(b) + if err != nil { + var missing *ssh.PassphraseMissingError + if errors.As(err, &missing) && opts.Passphrase != nil { + pass, perr := opts.Passphrase(f) + if perr == nil && len(pass) > 0 { + if signer, err = ssh.ParsePrivateKeyWithPassphrase(b, pass); err == nil { + opts.logf("identity %s: loaded (passphrase)", f) + add(signer) + continue + } + } + } + opts.logf("identity %s: skipped (%v)", f, err) + continue + } + opts.logf("identity %s: loaded", f) + add(signer) + } + + var methods []ssh.AuthMethod + switch { + case len(ordered) > 0: + // Offer exactly the configured identities — few, correct, in order. + methods = append(methods, ssh.PublicKeys(ordered...)) + case !p.IdentitiesOnly && len(agentSigners) > 0: + // No configured identity resolved; fall back to the agent's keys. + opts.logf("no configured identity resolved; offering all agent keys") + methods = append(methods, ssh.PublicKeys(agentSigners...)) + } + + // Password auth goes last so a working key is always preferred. Servers that + // only advertise keyboard-interactive for passwords need the second method; + // both draw on the same callback, which the GUI answers from one prompt. + if opts.Password != nil { + ask := func() (string, error) { + b, err := opts.Password(p.User, p.HostName) + return string(b), err + } + methods = append(methods, ssh.PasswordCallback(ask)) + methods = append(methods, ssh.KeyboardInteractive( + func(name, instruction string, questions []string, echos []bool) ([]string, error) { + answers := make([]string, len(questions)) + pass, err := ask() + if err != nil { + return nil, err + } + for i := range questions { + answers[i] = pass + } + return answers, nil + })) + opts.logf("password auth available as a fallback") + } + + if len(methods) == 0 { + return nil, errors.New("no usable SSH key: load your key into ssh-agent (ssh-add) " + + "or ensure the configured IdentityFile exists") + } + return methods, nil +} + +// dialAgent connects to the ssh-agent and returns its signers, or nil if the +// agent is unreachable. +func dialAgent(p Params, opts Options) []ssh.Signer { + sock := p.IdentityAgent + if sock == "" || sock == "SSH_AUTH_SOCK" { + sock = os.Getenv("SSH_AUTH_SOCK") + } + if sock == "" || sock == "none" { + opts.logf("ssh-agent: not configured (SSH_AUTH_SOCK unset)") + return nil + } + conn, err := net.Dial("unix", sock) + if err != nil { + opts.logf("ssh-agent: unreachable (%v)", err) + return nil + } + signers, err := agent.NewClient(conn).Signers() + if err != nil { + opts.logf("ssh-agent: error listing keys (%v)", err) + return nil + } + return signers +} + +// readPublicKey reads and parses an OpenSSH ".pub" file. +func readPublicKey(path string) (ssh.PublicKey, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + pub, _, _, _, err := ssh.ParseAuthorizedKey(b) + return pub, err +} + +// hostKeyCallback verifies the server against known_hosts. If strict checking +// is disabled it accepts any key. An unknown host is offered to +// Options.AcceptHostKey (and recorded if accepted) — quick connect has to be +// able to reach a host that has never been ssh'd to. A key that is present but +// *different* is always fatal: that is the attack this check exists for, and no +// prompt should make it dismissable. +func hostKeyCallback(p Params, opts Options) (ssh.HostKeyCallback, error) { + if strings.EqualFold(p.StrictHostKey, "no") { + return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec — user opted out via config + } + + files := p.KnownHostsFile + if len(files) == 0 { + if home, err := os.UserHomeDir(); err == nil { + files = []string{filepath.Join(home, ".ssh", "known_hosts")} + } + } + if len(files) == 0 { + return nil, errors.New("cannot locate a known_hosts file") + } + var existing []string + for _, f := range files { + if _, err := os.Stat(f); err == nil { + existing = append(existing, f) + } + } + // New keys are appended to the first configured file, which is the one ssh + // itself would write to. + writeTo := files[0] + + if len(existing) == 0 { + if opts.AcceptHostKey == nil { + return nil, errors.New("no known_hosts file found; ssh to this host once first to record its key") + } + // Nothing recorded yet: every host is unknown, so go straight to the prompt. + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + return acceptAndRecord(writeTo, hostname, key, opts) + }, nil + } + + known, err := knownhosts.New(existing...) + if err != nil { + return nil, fmt.Errorf("reading known_hosts: %w", err) + } + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + err := known(hostname, remote, key) + if err == nil { + return nil + } + var ke *knownhosts.KeyError + // A KeyError with no Want entries means the host is simply unrecorded; + // with Want entries it means the recorded key does not match. + if errors.As(err, &ke) && len(ke.Want) == 0 && opts.AcceptHostKey != nil { + return acceptAndRecord(writeTo, hostname, key, opts) + } + return err + }, nil +} + +// acceptAndRecord asks the user about an unrecorded host key and appends it to +// known_hosts when they accept. +func acceptAndRecord(file, hostname string, key ssh.PublicKey, opts Options) error { + ok, err := opts.AcceptHostKey(hostname, key) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("host key for %s was not accepted", hostname) + } + if err := appendKnownHost(file, hostname, key); err != nil { + // Recording is best-effort: failing to write the file shouldn't sink a + // connection the user just explicitly approved. + opts.logf("could not record host key in %s: %v", file, err) + } + return nil +} + +func appendKnownHost(file, hostname string, key ssh.PublicKey) error { + if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil { + return err + } + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer f.Close() + line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key) + _, err = fmt.Fprintln(f, line) + return err +} + +// FingerprintSHA256 renders a host key the way ssh presents it in its +// "authenticity of host ... can't be established" prompt, so a user can compare +// the two by eye. +func FingerprintSHA256(key ssh.PublicKey) string { + return ssh.FingerprintSHA256(key) +} + +func expandTilde(path string) string { + if path == "~" || strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + if path == "~" { + return home + } + return home + path[1:] + } + } + return path +} diff --git a/internal/sshx/sshx_test.go b/internal/sshx/sshx_test.go new file mode 100644 index 0000000..8dfb047 --- /dev/null +++ b/internal/sshx/sshx_test.go @@ -0,0 +1,68 @@ +package sshx + +import "testing" + +func TestParseSSHG(t *testing.T) { + // Representative `ssh -G` output (abridged, real keys/casing). + out := `host web1 +hostname 10.0.0.5 +user deploy +port 2222 +proxyjump bastion +identityfile ~/.ssh/id_ed25519 +identityfile ~/.ssh/id_rsa +userknownhostsfile ~/.ssh/known_hosts ~/.ssh/known_hosts2 +stricthostkeychecking accept-new +identityagent SSH_AUTH_SOCK +` + p := parseSSHG("web1", out) + if p.HostName != "10.0.0.5" { + t.Errorf("hostname = %q", p.HostName) + } + if p.User != "deploy" || p.Port != "2222" { + t.Errorf("user/port = %q/%q", p.User, p.Port) + } + if p.ProxyJump != "bastion" { + t.Errorf("proxyjump = %q", p.ProxyJump) + } + if len(p.IdentityFiles) != 2 { + t.Errorf("identityfiles = %v", p.IdentityFiles) + } + if len(p.KnownHostsFile) != 2 { + t.Errorf("knownhosts = %v", p.KnownHostsFile) + } + if p.StrictHostKey != "accept-new" { + t.Errorf("stricthostkey = %q", p.StrictHostKey) + } + if p.Addr() != "10.0.0.5:2222" { + t.Errorf("addr = %q", p.Addr()) + } +} + +func TestParseSSHGIdentitiesOnly(t *testing.T) { + p := parseSSHG("h", "hostname h\nidentitiesonly yes\nidentityfile ~/.ssh/special\n") + if !p.IdentitiesOnly { + t.Error("identitiesonly yes should parse true") + } + q := parseSSHG("h", "hostname h\nidentitiesonly no\n") + if q.IdentitiesOnly { + t.Error("identitiesonly no should parse false") + } +} + +func TestParseSSHGProxyJumpNone(t *testing.T) { + p := parseSSHG("h", "hostname h.example.com\nproxyjump none\nport 22\n") + if p.ProxyJump != "" { + t.Errorf("proxyjump 'none' should be empty, got %q", p.ProxyJump) + } + if p.Addr() != "h.example.com:22" { + t.Errorf("addr = %q", p.Addr()) + } +} + +func TestParseSSHGFallbackHostname(t *testing.T) { + p := parseSSHG("myhost", "user bob\n") + if p.HostName != "myhost" { + t.Errorf("hostname should fall back to alias, got %q", p.HostName) + } +} diff --git a/internal/ui/connect.go b/internal/ui/connect.go new file mode 100644 index 0000000..64a2dff --- /dev/null +++ b/internal/ui/connect.go @@ -0,0 +1,388 @@ +package ui + +import ( + "fmt" + "os" + "strings" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + + "gitea.apointless.space/bsncubed/haul/internal/sshconf" + "gitea.apointless.space/bsncubed/haul/internal/sshx" +) + +// connectWindow is haul's front door: the hosts already in ~/.ssh/config on one +// tab, an ad-hoc target on the other. It stays alive behind each session so +// closing a session returns here rather than quitting. +type connectWindow struct { + app fyne.App + win fyne.Window + + cfgPath string + hosts []sshconf.Host // everything parsed from the config + shown []sshconf.Host // what the filter currently leaves visible + list *widget.List + selected int + + qHost *widget.Entry + qPort *widget.Entry + qUser *widget.Entry + qPass *widget.Entry + qKey *widget.Entry + + tabs *container.AppTabs + connectBtn *widget.Button + status *widget.Label + loadErr string // config problem found before the status label existed +} + +func newConnectWindow(app fyne.App) *connectWindow { + c := &connectWindow{ + app: app, + win: app.NewWindow("haul"), + selected: -1, + } + + c.loadHosts() + savedTab := container.NewTabItemWithIcon("Saved hosts", theme.ComputerIcon(), c.buildSavedTab()) + quickTab := container.NewTabItemWithIcon("Quick connect", theme.SearchIcon(), c.buildQuickTab()) + c.tabs = container.NewAppTabs(savedTab, quickTab) + + c.status = widget.NewLabel(c.loadErr) + c.status.Wrapping = fyne.TextWrapWord + c.connectBtn = widget.NewButtonWithIcon("Connect", theme.ConfirmIcon(), c.connect) + c.connectBtn.Importance = widget.HighImportance + + bottom := container.NewBorder(widget.NewSeparator(), nil, nil, c.connectBtn, c.status) + c.win.SetContent(container.NewBorder(nil, bottom, nil, nil, c.tabs)) + c.win.Resize(fyne.NewSize(620, 520)) + return c +} + +// --- saved hosts --- + +func (c *connectWindow) loadHosts() { + path, err := sshconf.DefaultConfigPath() + if err != nil { + return + } + c.cfgPath = path + hosts, err := sshconf.Parse(path) + if err != nil { + // A malformed config shouldn't stop haul launching — quick connect still + // works — so the problem is held here and shown once the window is built. + c.loadErr = "Could not read " + path + ": " + err.Error() + return + } + c.hosts = hosts + c.shown = hosts +} + +func (c *connectWindow) buildSavedTab() fyne.CanvasObject { + search := widget.NewEntry() + search.SetPlaceHolder("Filter hosts…") + search.OnChanged = c.filter + + c.list = widget.NewList( + func() int { return len(c.shown) }, + func() fyne.CanvasObject { + return container.NewVBox( + widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + widget.NewLabel(""), + ) + }, + func(id widget.ListItemID, o fyne.CanvasObject) { + box, ok := o.(*fyne.Container) + if !ok || id < 0 || id >= len(c.shown) { + return + } + h := c.shown[id] + alias := box.Objects[0].(*widget.Label) + detail := box.Objects[1].(*widget.Label) + alias.SetText(h.Alias) + detail.SetText(describeHost(h)) + }, + ) + c.list.OnSelected = func(id widget.ListItemID) { c.selected = id } + + // The "no hosts" note is only added when it has something to say — an empty + // label still occupies a full row, which reads as a rendering glitch. + top := fyne.CanvasObject(search) + if len(c.hosts) == 0 { + note := widget.NewLabel("No hosts found in " + c.cfgPath + " — use Quick connect.") + note.Wrapping = fyne.TextWrapWord + top = container.NewVBox(search, note) + } + + return container.NewBorder(top, nil, nil, nil, c.list) +} + +// describeHost is the one-line summary under each alias in the list. +func describeHost(h sshconf.Host) string { + target := h.HostName + if target == "" { + target = h.Alias + } + if h.User != "" { + target = h.User + "@" + target + } + if h.Port != "" && h.Port != "22" { + target += ":" + h.Port + } + if h.ProxyJump != "" { + target += " (via " + h.ProxyJump + ")" + } + if len(h.Tags) > 0 { + target += " [" + strings.Join(h.Tags, ", ") + "]" + } + return target +} + +func (c *connectWindow) filter(q string) { + q = strings.ToLower(strings.TrimSpace(q)) + if q == "" { + c.shown = c.hosts + } else { + c.shown = nil + for _, h := range c.hosts { + if strings.Contains(strings.ToLower(h.Alias), q) || + strings.Contains(strings.ToLower(h.HostName), q) || + strings.Contains(strings.ToLower(strings.Join(h.Tags, " ")), q) { + c.shown = append(c.shown, h) + } + } + } + c.selected = -1 + c.list.UnselectAll() + c.list.Refresh() +} + +// --- quick connect --- + +func (c *connectWindow) buildQuickTab() fyne.CanvasObject { + c.qHost = widget.NewEntry() + c.qHost.SetPlaceHolder("hostname or IP") + c.qPort = widget.NewEntry() + c.qPort.SetText("22") + c.qUser = widget.NewEntry() + c.qUser.SetText(currentUser()) + c.qPass = widget.NewPasswordEntry() + c.qPass.SetPlaceHolder("leave blank to use ssh-agent / key") + c.qKey = widget.NewEntry() + c.qKey.SetPlaceHolder("optional private key file") + + browse := widget.NewButtonWithIcon("", theme.FolderOpenIcon(), func() { + dialog.ShowFileOpen(func(r fyne.URIReadCloser, err error) { + if err != nil || r == nil { + return + } + defer r.Close() + c.qKey.SetText(r.URI().Path()) + }, c.win) + }) + + form := widget.NewForm( + widget.NewFormItem("Host", c.qHost), + widget.NewFormItem("Port", c.qPort), + widget.NewFormItem("User", c.qUser), + widget.NewFormItem("Password", c.qPass), + widget.NewFormItem("Key file", container.NewBorder(nil, nil, nil, browse, c.qKey)), + ) + // Enter anywhere in the form connects. + c.qHost.OnSubmitted = func(string) { c.connect() } + c.qPass.OnSubmitted = func(string) { c.connect() } + + note := widget.NewLabel("Quick connect doesn't touch ~/.ssh/config. An unrecognised host key is " + + "shown for confirmation before it's recorded in known_hosts.") + note.Wrapping = fyne.TextWrapWord + + return container.NewVBox(form, widget.NewSeparator(), note) +} + +func currentUser() string { + for _, k := range []string{"USER", "USERNAME", "LOGNAME"} { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} + +// --- connecting --- + +func (c *connectWindow) connect() { + if c.tabs.SelectedIndex() == 1 { + c.connectQuick() + return + } + c.connectSaved() +} + +func (c *connectWindow) connectSaved() { + if c.selected < 0 || c.selected >= len(c.shown) { + c.setStatus("Select a host first.") + return + } + alias := c.shown[c.selected].Alias + c.start(alias, "", func(opts sshx.Options) (*ssh.Client, error) { + return sshx.Connect(alias, opts) + }) +} + +func (c *connectWindow) connectQuick() { + host := strings.TrimSpace(c.qHost.Text) + if host == "" { + c.setStatus("Enter a hostname.") + return + } + // "user@host:port" typed into the host box is understood, since that's how + // people paste a target. + user := strings.TrimSpace(c.qUser.Text) + port := strings.TrimSpace(c.qPort.Text) + if u, rest, ok := strings.Cut(host, "@"); ok { + user, host = u, rest + } + if h, p, ok := strings.Cut(host, ":"); ok { + host, port = h, p + } + if port == "" { + port = "22" + } + + p := sshx.Params{ + Alias: host, + HostName: host, + User: user, + Port: port, + } + if key := strings.TrimSpace(c.qKey.Text); key != "" { + p.IdentityFiles = []string{key} + p.IdentitiesOnly = true + } + label := host + if user != "" { + label = user + "@" + host + } + c.start(label, c.qPass.Text, func(opts sshx.Options) (*ssh.Client, error) { + return sshx.ConnectParams(p, opts) + }) +} + +// start runs a connection attempt off the UI thread and opens a session window +// on success. dial is whichever sshx entry point the chosen tab implies. +func (c *connectWindow) start(name, password string, dial func(sshx.Options) (*ssh.Client, error)) { + c.connectBtn.Disable() + c.setStatus("Connecting to " + name + "…") + + go func() { + var diagMu sync.Mutex + var diag []string + + opts := c.sshOptions(password, func(format string, args ...any) { + diagMu.Lock() + defer diagMu.Unlock() + diag = append(diag, fmt.Sprintf(format, args...)) + }) + + client, err := dial(opts) + if err != nil { + diagMu.Lock() + detail := strings.Join(diag, "\n · ") + diagMu.Unlock() + fyne.Do(func() { + c.connectBtn.Enable() + c.setStatus("") + c.showConnectError(err, detail) + }) + return + } + + sf, err := sftp.NewClient(client) + if err != nil { + client.Close() + fyne.Do(func() { + c.connectBtn.Enable() + c.setStatus("") + dialog.ShowError(fmt.Errorf("opening SFTP subsystem: %w", err), c.win) + }) + return + } + + fyne.Do(func() { + c.connectBtn.Enable() + c.setStatus("") + c.win.Hide() + newSession(c.app, name, client, sf, func() { c.win.Show() }) + }) + }() +} + +// sshOptions wires sshx's blocking callbacks to dialogs on the UI thread. Each +// callback is invoked from the connecting goroutine and waits for an answer. +func (c *connectWindow) sshOptions(initialPassword string, logf func(string, ...any)) sshx.Options { + var mu sync.Mutex + cached := initialPassword + + return sshx.Options{ + Passphrase: func(keyfile string) ([]byte, error) { + reply := make(chan []byte, 1) + fyne.Do(func() { + askSecret(c.win, "Key passphrase", "Enter the passphrase for "+keyfile, reply) + }) + return <-reply, nil + }, + Password: func(user, host string) ([]byte, error) { + mu.Lock() + defer mu.Unlock() + // A password typed on the quick-connect form is reused for the + // keyboard-interactive round rather than prompting twice. + if cached != "" { + return []byte(cached), nil + } + reply := make(chan []byte, 1) + target := host + if user != "" { + target = user + "@" + host + } + fyne.Do(func() { + askSecret(c.win, "Password", "Password for "+target, reply) + }) + b := <-reply + cached = string(b) + return b, nil + }, + AcceptHostKey: func(host string, key ssh.PublicKey) (bool, error) { + reply := make(chan bool, 1) + fyne.Do(func() { askHostKey(c.win, host, key, reply) }) + return <-reply, nil + }, + Logf: logf, + } +} + +// showConnectError presents the failure with the auth diagnostics sshx +// collected, which is usually what actually explains it. +func (c *connectWindow) showConnectError(err error, detail string) { + msg := err.Error() + if detail != "" { + msg += "\n\nAuth diagnostics:\n · " + detail + } + label := widget.NewLabel(msg) + label.Wrapping = fyne.TextWrapWord + d := dialog.NewCustom("Connection failed", "Close", container.NewVScroll(label), c.win) + d.Resize(fyne.NewSize(560, 320)) + d.Show() +} + +func (c *connectWindow) setStatus(s string) { c.status.SetText(s) } + +func (c *connectWindow) showAndRun() { + c.win.ShowAndRun() +} diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go new file mode 100644 index 0000000..1d2411f --- /dev/null +++ b/internal/ui/dialogs.go @@ -0,0 +1,144 @@ +package ui + +import ( + "fmt" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + "golang.org/x/crypto/ssh" + + "gitea.apointless.space/bsncubed/haul/internal/sshx" + "gitea.apointless.space/bsncubed/haul/internal/xfer" +) + +// The prompts in this file are all shaped the same way, because they are all +// answers a background goroutine is blocked waiting for: the goroutine makes a +// buffered channel, asks for the prompt to be shown on the UI thread, and reads +// its answer. Every dismissal path — button, escape, window close — must send +// exactly once, or the transfer goroutine hangs forever holding the connection. +// sync.Once is what guarantees the "exactly". + +func confirmDialog(title, msg string, win fyne.Window, cb func(bool)) { + dialog.ShowConfirm(title, msg, cb, win) +} + +func newFormDialog(title, confirm string, items []*widget.FormItem, win fyne.Window, cb func(bool)) dialog.Dialog { + d := dialog.NewForm(title, confirm, "Cancel", items, cb, win) + d.Resize(fyne.NewSize(420, 0)) + return d +} + +// conflictAnswer is the user's decision about one pre-existing destination file. +type conflictAnswer struct { + choice xfer.Conflict + all bool // apply this decision to every remaining conflict +} + +// askConflict shows the overwrite prompt. It must be called on the UI thread. +func askConflict(win fyne.Window, name string, reply chan<- conflictAnswer) { + var once sync.Once + send := func(a conflictAnswer) { once.Do(func() { reply <- a }) } + + all := widget.NewCheck("Apply to all remaining conflicts", nil) + msg := widget.NewLabel(fmt.Sprintf("%s already exists at the destination.", name)) + msg.Wrapping = fyne.TextWrapWord + + var d *dialog.CustomDialog + button := func(label string, c xfer.Conflict) *widget.Button { + return widget.NewButton(label, func() { + send(conflictAnswer{choice: c, all: all.Checked}) + d.Hide() + }) + } + overwrite := button("Overwrite", xfer.Overwrite) + overwrite.Importance = widget.HighImportance + + content := container.NewVBox( + msg, + all, + container.NewHBox(layout.NewSpacer(), + button("Skip", xfer.Skip), + button("Keep both", xfer.Rename), + overwrite, + ), + ) + d = dialog.NewCustomWithoutButtons("File exists", content, win) + // Dismissing without choosing is treated as "skip this file": the safe + // reading of "I didn't answer" is "don't destroy anything". + d.SetOnClosed(func() { send(conflictAnswer{choice: xfer.Skip}) }) + d.Resize(fyne.NewSize(460, 0)) + d.Show() +} + +// askSecret prompts for a password or key passphrase. An empty result means the +// user declined, which callers pass on to sshx as "skip this method". +func askSecret(win fyne.Window, title, prompt string, reply chan<- []byte) { + var once sync.Once + send := func(b []byte) { once.Do(func() { reply <- b }) } + + entry := widget.NewPasswordEntry() + label := widget.NewLabel(prompt) + label.Wrapping = fyne.TextWrapWord + + var d *dialog.CustomDialog + submit := func() { + send([]byte(entry.Text)) + d.Hide() + } + entry.OnSubmitted = func(string) { submit() } + + ok := widget.NewButton("OK", submit) + ok.Importance = widget.HighImportance + cancel := widget.NewButton("Cancel", func() { + send(nil) + d.Hide() + }) + + content := container.NewVBox( + label, + entry, + container.NewHBox(layout.NewSpacer(), cancel, ok), + ) + d = dialog.NewCustomWithoutButtons(title, content, win) + d.SetOnClosed(func() { send(nil) }) + d.Resize(fyne.NewSize(420, 0)) + d.Show() + win.Canvas().Focus(entry) +} + +// askHostKey shows the unknown-host prompt, the GUI equivalent of ssh's +// "authenticity of host ... can't be established". The fingerprint is shown in +// the same SHA256 form ssh prints, so it can be compared by eye. +func askHostKey(win fyne.Window, host string, key ssh.PublicKey, reply chan<- bool) { + var once sync.Once + send := func(b bool) { once.Do(func() { reply <- b }) } + + msg := widget.NewLabel(fmt.Sprintf( + "The authenticity of host %s can't be established.\n\n%s key fingerprint:", host, key.Type())) + msg.Wrapping = fyne.TextWrapWord + fp := widget.NewLabelWithStyle(sshx.FingerprintSHA256(key), fyne.TextAlignCenter, fyne.TextStyle{Monospace: true}) + warn := widget.NewLabel("Only continue if you recognise this fingerprint. It will be added to your known_hosts file.") + warn.Wrapping = fyne.TextWrapWord + + var d *dialog.CustomDialog + accept := widget.NewButton("Connect and record key", func() { + send(true) + d.Hide() + }) + accept.Importance = widget.HighImportance + reject := widget.NewButton("Cancel", func() { + send(false) + d.Hide() + }) + + content := container.NewVBox(msg, fp, warn, + container.NewHBox(layout.NewSpacer(), reject, accept)) + d = dialog.NewCustomWithoutButtons("Unknown host key", content, win) + d.SetOnClosed(func() { send(false) }) + d.Resize(fyne.NewSize(520, 0)) + d.Show() +} diff --git a/internal/ui/pane.go b/internal/ui/pane.go new file mode 100644 index 0000000..3f5d850 --- /dev/null +++ b/internal/ui/pane.go @@ -0,0 +1,442 @@ +package ui + +import ( + "fmt" + "sort" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "gitea.apointless.space/bsncubed/haul/internal/vfs" +) + +// Column layout, shared by the header and the cells. +const ( + colMark = iota + colName + colSize + colModified + numCols +) + +// item is one row of a directory listing. +type item struct { + name string + isDir bool + size int64 + mod time.Time + up bool // the synthetic ".." row +} + +// pane is one side of the browser: a path bar, a file table, and a footer. Both +// the local disk and the remote host are the same widget over a different +// vfs.FS, so anything that works on one works on the other. +type pane struct { + fs vfs.FS + title string + + cwd string + entries []item + marked map[string]bool // names in cwd marked for transfer + cursor int // highlighted row, or -1 for none + + table *widget.Table + pathEntry *widget.Entry + footer *widget.Label + titleLbl *widget.Label + root fyne.CanvasObject + + win fyne.Window + onError func(error) + // onFocus tells the session which pane the user last touched, so toolbar + // actions and keyboard shortcuts know what they apply to. + onFocus func(*pane) +} + +func newPane(win fyne.Window, filesys vfs.FS, title string, onError func(error), onFocus func(*pane)) *pane { + p := &pane{ + fs: filesys, + title: title, + marked: map[string]bool{}, + cursor: -1, + win: win, + onError: onError, + onFocus: onFocus, + } + + p.titleLbl = widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + p.footer = widget.NewLabel("") + + p.pathEntry = widget.NewEntry() + p.pathEntry.SetPlaceHolder("path") + // Enter in the path bar jumps straight there — the fastest way to a deep + // directory, and the reason the path is an entry rather than a label. + p.pathEntry.OnSubmitted = func(s string) { + p.navigate(vfs.CleanPath(p.fs, s)) + } + + p.table = widget.NewTable(p.tableSize, p.createCell, p.updateCell) + p.table.ShowHeaderRow = true + p.table.CreateHeader = func() fyne.CanvasObject { + return widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + } + p.table.UpdateHeader = p.updateHeader + p.table.OnSelected = p.onSelect + p.table.SetColumnWidth(colMark, 32) + p.table.SetColumnWidth(colName, 300) + p.table.SetColumnWidth(colSize, 96) + p.table.SetColumnWidth(colModified, 150) + + upBtn := widget.NewButtonWithIcon("", theme.MoveUpIcon(), func() { p.up() }) + homeBtn := widget.NewButtonWithIcon("", theme.HomeIcon(), func() { p.navigate(p.fs.Home()) }) + refreshBtn := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { p.reload() }) + + nav := container.NewBorder(nil, nil, + container.NewHBox(upBtn, homeBtn), + refreshBtn, + p.pathEntry, + ) + + actions := container.NewHBox( + widget.NewButtonWithIcon("New folder", theme.FolderNewIcon(), p.promptMkdir), + widget.NewButtonWithIcon("Rename", theme.DocumentCreateIcon(), p.promptRename), + widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), p.promptDelete), + ) + + top := container.NewVBox(p.titleLbl, nav) + bottom := container.NewVBox(actions, p.footer) + p.root = container.NewBorder(top, bottom, nil, nil, p.table) + return p +} + +// --- table plumbing --- + +func (p *pane) tableSize() (int, int) { return len(p.entries), numCols } + +func (p *pane) createCell() fyne.CanvasObject { + l := widget.NewLabel("") + l.Truncation = fyne.TextTruncateEllipsis + return l +} + +func (p *pane) updateCell(id widget.TableCellID, o fyne.CanvasObject) { + l, ok := o.(*widget.Label) + if !ok || id.Row < 0 || id.Row >= len(p.entries) { + return + } + it := p.entries[id.Row] + // Fields are assigned rather than SetText'd so each cell refreshes once; + // update runs for every visible cell on every scroll tick. + l.Alignment = fyne.TextAlignLeading + l.TextStyle = fyne.TextStyle{} + + switch id.Col { + case colMark: + l.Alignment = fyne.TextAlignCenter + l.Text = "" + if p.marked[it.name] { + l.Text = "✓" + } + case colName: + // Directories are bold and slash-suffixed: at a glance you can tell what + // you can descend into without hunting for an icon column. + if it.isDir { + l.TextStyle = fyne.TextStyle{Bold: true} + l.Text = it.name + "/" + } else { + l.Text = it.name + } + case colSize: + l.Alignment = fyne.TextAlignTrailing + l.Text = "" + if !it.isDir && !it.up { + l.Text = humanBytes(it.size) + } + case colModified: + l.Text = "" + if !it.up && !it.mod.IsZero() { + l.Text = it.mod.Format("2006-01-02 15:04") + } + } + l.Refresh() +} + +func (p *pane) updateHeader(id widget.TableCellID, o fyne.CanvasObject) { + l, ok := o.(*widget.Label) + if !ok { + return + } + l.Alignment = fyne.TextAlignLeading + l.Text = "" + switch id.Col { + case colMark: + l.Alignment = fyne.TextAlignCenter + l.Text = "✓" + case colName: + l.Text = "Name" + case colSize: + l.Alignment = fyne.TextAlignTrailing + l.Text = "Size" + case colModified: + l.Text = "Modified" + } + l.Refresh() +} + +// onSelect turns a cell click into the action that cell means: the tick column +// marks a row for transfer, a directory name descends into it, and anything +// else just moves the highlight. +func (p *pane) onSelect(id widget.TableCellID) { + if p.onFocus != nil { + p.onFocus(p) + } + if id.Row < 0 || id.Row >= len(p.entries) { + return + } + p.cursor = id.Row + it := p.entries[id.Row] + + switch { + case id.Col == colMark && !it.up: + if p.marked[it.name] { + delete(p.marked, it.name) + } else { + p.marked[it.name] = true + } + p.table.Refresh() + p.updateFooter() + case id.Col == colName && it.up: + p.up() + case id.Col == colName && it.isDir: + p.navigate(p.fs.Join(p.cwd, it.name)) + } +} + +// --- navigation --- + +func (p *pane) up() { + if p.fs.IsRoot(p.cwd) { + return + } + p.navigate(p.fs.Dir(p.cwd)) +} + +func (p *pane) reload() { p.navigateKeepMarks(p.cwd, true) } + +// navigate moves to dir, clearing marks — they refer to names in the directory +// being left, so carrying them across would transfer the wrong thing. +func (p *pane) navigate(dir string) { p.navigateKeepMarks(dir, false) } + +// navigateKeepMarks does the directory read off the UI thread. Remote listings +// are network round-trips, and a GUI that freezes while one is in flight is the +// main thing that makes an SFTP client feel slow. +func (p *pane) navigateKeepMarks(dir string, keepMarks bool) { + p.pathEntry.SetText(dir) + go func() { + entries, err := readDir(p.fs, dir) + fyne.Do(func() { + if err != nil { + // Put the box back to where we actually still are. + p.pathEntry.SetText(p.cwd) + p.onError(err) + return + } + sameDir := dir == p.cwd + p.cwd = dir + p.entries = entries + if !keepMarks || !sameDir { + p.marked = map[string]bool{} + } else { + p.pruneMarks() + } + p.table.UnselectAll() + p.cursor = -1 + p.table.Refresh() + if !sameDir { + p.table.ScrollToTop() + } + p.updateFooter() + }) + }() +} + +// pruneMarks drops marks for names that no longer exist after a refresh. +func (p *pane) pruneMarks() { + present := make(map[string]bool, len(p.entries)) + for _, it := range p.entries { + present[it.name] = true + } + for name := range p.marked { + if !present[name] { + delete(p.marked, name) + } + } +} + +// readDir lists dir, sorting directories first then by name, and prepends the +// ".." row unless dir is a root. +func readDir(filesys vfs.FS, dir string) ([]item, error) { + infos, err := filesys.ReadDir(dir) + if err != nil { + return nil, err + } + items := make([]item, 0, len(infos)+1) + for _, fi := range infos { + items = append(items, item{ + name: fi.Name(), + isDir: fi.IsDir(), + size: fi.Size(), + mod: fi.ModTime(), + }) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].isDir != items[j].isDir { + return items[i].isDir + } + return items[i].name < items[j].name + }) + if !filesys.IsRoot(dir) { + items = append([]item{{name: "..", isDir: true, up: true}}, items...) + } + return items, nil +} + +// --- selection --- + +// sources returns the absolute paths a transfer should act on: everything +// marked, or the highlighted row if nothing is marked. That second case is what +// makes the common "send this one file" case a two-click operation. +func (p *pane) sources() []string { + if len(p.marked) > 0 { + out := make([]string, 0, len(p.marked)) + // Walk entries, not the map, so the order matches what's on screen. + for _, it := range p.entries { + if p.marked[it.name] { + out = append(out, p.fs.Join(p.cwd, it.name)) + } + } + return out + } + if it, ok := p.highlighted(); ok && !it.up { + return []string{p.fs.Join(p.cwd, it.name)} + } + return nil +} + +// highlighted returns the row the user last clicked, if it is still in range. +func (p *pane) highlighted() (item, bool) { + if p.cursor < 0 || p.cursor >= len(p.entries) { + return item{}, false + } + return p.entries[p.cursor], true +} + +func (p *pane) updateFooter() { + var files, dirs int + var bytes int64 + for _, it := range p.entries { + switch { + case it.up: + case it.isDir: + dirs++ + default: + files++ + bytes += it.size + } + } + text := fmt.Sprintf("%d dirs, %d files (%s)", dirs, files, humanBytes(bytes)) + if n := len(p.marked); n > 0 { + text += fmt.Sprintf(" — %d marked", n) + } + p.footer.SetText(text) +} + +// --- file operations --- + +func (p *pane) promptMkdir() { + entry := widget.NewEntry() + entry.SetPlaceHolder("name") + formItem := widget.NewFormItem("Folder", entry) + d := newFormDialog("New folder in "+p.cwd, "Create", []*widget.FormItem{formItem}, p.win, func(ok bool) { + if !ok || entry.Text == "" { + return + } + p.runOp(func() error { return p.fs.Mkdir(p.fs.Join(p.cwd, entry.Text)) }) + }) + d.Show() +} + +func (p *pane) promptRename() { + it, ok := p.highlighted() + if !ok || it.up { + p.onError(fmt.Errorf("select a file or folder to rename")) + return + } + entry := widget.NewEntry() + entry.SetText(it.name) + formItem := widget.NewFormItem("New name", entry) + d := newFormDialog("Rename "+it.name, "Rename", []*widget.FormItem{formItem}, p.win, func(ok bool) { + if !ok || entry.Text == "" || entry.Text == it.name { + return + } + p.runOp(func() error { + return p.fs.Rename(p.fs.Join(p.cwd, it.name), p.fs.Join(p.cwd, entry.Text)) + }) + }) + d.Show() +} + +func (p *pane) promptDelete() { + targets := p.sources() + if len(targets) == 0 { + p.onError(fmt.Errorf("select or mark something to delete")) + return + } + msg := fmt.Sprintf("Delete %d item(s) from %s?\n\nDirectories are deleted with everything inside them. This cannot be undone.", + len(targets), p.title) + confirmDialog("Confirm delete", msg, p.win, func(ok bool) { + if !ok { + return + } + p.runOp(func() error { + for _, t := range targets { + if err := p.fs.Remove(t); err != nil { + return err + } + } + return nil + }) + }) +} + +// runOp performs a filesystem mutation off the UI thread and refreshes the pane +// when it finishes. +func (p *pane) runOp(fn func() error) { + go func() { + err := fn() + fyne.Do(func() { + if err != nil { + p.onError(err) + } + p.marked = map[string]bool{} + p.reload() + }) + }() +} + +// humanBytes formats a byte count in units that fit a narrow column. +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit && exp < 4; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp]) +} diff --git a/internal/ui/session.go b/internal/ui/session.go new file mode 100644 index 0000000..94452fd --- /dev/null +++ b/internal/ui/session.go @@ -0,0 +1,268 @@ +package ui + +import ( + "errors" + "fmt" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + + "gitea.apointless.space/bsncubed/haul/internal/vfs" + "gitea.apointless.space/bsncubed/haul/internal/xfer" +) + +// session is one connected host: a window with the local disk on the left, the +// remote host on the right, and the transfer controls between them. It owns the +// SSH and SFTP connections and closes them when the window closes. +type session struct { + win fyne.Window + client *ssh.Client + sf *sftp.Client + engine *xfer.Engine + + local *pane + remote *pane + active *pane + + uploadBtn *widget.Button + downloadBtn *widget.Button + stopBtn *widget.Button + progBar *widget.ProgressBar + progLabel *widget.Label + + busy bool + // onClose brings the connection window back when this session ends. + onClose func() +} + +// newSession builds and shows the browser window for an established connection. +func newSession(app fyne.App, name string, client *ssh.Client, sf *sftp.Client, onClose func()) *session { + s := &session{ + win: app.NewWindow("haul — " + name), + client: client, + sf: sf, + engine: xfer.New(sf), + onClose: onClose, + } + + s.local = newPane(s.win, vfs.Local{}, "LOCAL", s.showError, s.setActive) + s.remote = newPane(s.win, vfs.Remote{C: sf}, "REMOTE — "+name, s.showError, s.setActive) + s.active = s.local + + // The arrows in the labels carry the direction; an icon next to them only + // competes with it, since up/down icons say nothing about left/right panes. + s.uploadBtn = widget.NewButton("Upload →", func() { s.transfer(true) }) + s.downloadBtn = widget.NewButton("← Download", func() { s.transfer(false) }) + // Disconnect goes through the same guard as the window's close button, so a + // running transfer is never dropped without asking. + disconnect := widget.NewButtonWithIcon("Disconnect", theme.LogoutIcon(), s.closeRequested) + + s.progBar = widget.NewProgressBar() + // An empty bar sitting at 0% reads as "stuck", so it only appears once + // there's a transfer to report on, and stays afterwards showing the result. + s.progBar.Hide() + s.progLabel = widget.NewLabel("Ready") + s.stopBtn = widget.NewButtonWithIcon("Stop", theme.CancelIcon(), func() { s.engine.Cancel() }) + s.stopBtn.Disable() + + controls := container.NewHBox(s.uploadBtn, s.downloadBtn) + transferBar := container.NewBorder(nil, nil, controls, disconnect) + progressBar := container.NewBorder(nil, nil, nil, s.stopBtn, + container.NewBorder(nil, nil, nil, s.progLabel, s.progBar)) + + split := container.NewHSplit(s.local.root, s.remote.root) + split.SetOffset(0.5) + + s.win.SetContent(container.NewBorder( + nil, + container.NewVBox(widget.NewSeparator(), transferBar, progressBar), + nil, nil, + split, + )) + s.win.Resize(fyne.NewSize(1180, 720)) + s.win.SetCloseIntercept(s.closeRequested) + s.registerShortcuts() + + s.local.navigate(s.local.fs.Home()) + s.remote.navigate(s.remote.fs.Home()) + s.win.Show() + return s +} + +func (s *session) setActive(p *pane) { s.active = p } + +func (s *session) other(p *pane) *pane { + if p == s.local { + return s.remote + } + return s.local +} + +func (s *session) registerShortcuts() { + add := func(key fyne.KeyName, fn func()) { + s.win.Canvas().AddShortcut( + &desktop.CustomShortcut{KeyName: key, Modifier: fyne.KeyModifierControl}, + func(fyne.Shortcut) { fn() }, + ) + } + add(fyne.KeyR, func() { s.active.reload() }) + add(fyne.KeyUp, func() { s.active.up() }) + // Ctrl+T sends the active pane's selection to the other side, so the same + // key uploads or downloads depending on which pane you were last in. + add(fyne.KeyT, func() { s.transfer(s.active == s.local) }) +} + +// --- transfers --- + +// transfer copies the source pane's marked entries (or its highlighted row) +// into the other pane's current directory. +func (s *session) transfer(push bool) { + if s.busy { + return + } + src, dst := s.remote, s.local + if push { + src, dst = s.local, s.remote + } + sources := src.sources() + if len(sources) == 0 { + s.showError(fmt.Errorf("mark or select something in %s first", src.title)) + return + } + destDir := dst.cwd + s.setBusy(true) + s.progBar.SetValue(0) + + go func() { + // applyAll lives in this goroutine alone, so the "apply to all" answer + // needs no synchronisation with the UI. + var applyAll *xfer.Conflict + + s.engine.OnProgress = s.progressSink() + s.engine.OnConflict = func(name string) xfer.Conflict { + if applyAll != nil { + return *applyAll + } + reply := make(chan conflictAnswer, 1) + fyne.Do(func() { askConflict(s.win, name, reply) }) + a := <-reply + if a.all { + c := a.choice + applyAll = &c + } + return a.choice + } + + var err error + if push { + err = s.engine.Push(sources, destDir) + } else { + err = s.engine.Pull(sources, destDir) + } + + fyne.Do(func() { + s.setBusy(false) + src.marked = map[string]bool{} + src.table.Refresh() + src.updateFooter() + dst.reload() // show what just landed + switch { + case err == nil: + s.progBar.SetValue(1) + s.progLabel.SetText(fmt.Sprintf("Transferred %d item(s)", len(sources))) + case errors.Is(err, xfer.ErrCanceled): + s.progLabel.SetText("Transfer stopped — partial files can be resumed by transferring again") + default: + s.progLabel.SetText("Transfer failed") + s.showError(err) + } + }) + }() +} + +// progressSink returns the engine's progress callback, rate-limited before it +// reaches the UI thread. The engine reports on every write — tens of thousands +// of times for a large file — and repainting at that rate is slower than the +// transfer itself. +func (s *session) progressSink() xfer.ProgressFunc { + // Only the transfer goroutine calls this, so last needs no locking. + var last time.Time + const minInterval = 60 * time.Millisecond + return func(p xfer.Progress) { + now := time.Now() + done := p.FilesTotal > 0 && p.FilesDone == p.FilesTotal + if !done && now.Sub(last) < minInterval { + return + } + last = now + fyne.Do(func() { s.paintProgress(p) }) + } +} + +func (s *session) paintProgress(p xfer.Progress) { + if p.BytesTotal > 0 { + s.progBar.SetValue(float64(p.BytesDone) / float64(p.BytesTotal)) + } + s.progLabel.SetText(fmt.Sprintf("%d/%d files · %s of %s · %s", + p.FilesDone, p.FilesTotal, humanBytes(p.BytesDone), humanBytes(p.BytesTotal), p.CurrentName)) +} + +func (s *session) setBusy(busy bool) { + s.busy = busy + if busy { + s.progBar.Show() + s.uploadBtn.Disable() + s.downloadBtn.Disable() + s.stopBtn.Enable() + return + } + s.uploadBtn.Enable() + s.downloadBtn.Enable() + s.stopBtn.Disable() +} + +// --- lifecycle --- + +func (s *session) showError(err error) { + if err == nil { + return + } + dialog.ShowError(err, s.win) +} + +// closeRequested guards against closing the window out from under a running +// transfer, which would kill the connection mid-file. +func (s *session) closeRequested() { + if !s.busy { + s.shutdown() + return + } + confirmDialog("Transfer in progress", + "A transfer is still running. Stop it and disconnect?", s.win, func(ok bool) { + if !ok { + return + } + s.engine.Cancel() + s.shutdown() + }) +} + +func (s *session) shutdown() { + if s.sf != nil { + s.sf.Close() + } + if s.client != nil { + s.client.Close() + } + s.win.Close() + if s.onClose != nil { + s.onClose() + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..b706d53 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,97 @@ +// Package ui is haul's desktop GUI: a connection window backed by ~/.ssh/config +// (plus ad-hoc quick connect), and a two-pane local/remote file browser per +// connected host. +// +// The rule the whole package follows: Fyne widgets are only ever touched from +// the UI thread. Every SFTP call — listing a directory, transferring, deleting +// — runs on its own goroutine and comes back through fyne.Do. A GUI that blocks +// its event loop on a network round-trip is exactly the thing that makes an +// SFTP client feel slow, and remote round-trips are the common case here. +package ui + +import ( + "image/color" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/theme" +) + +// Run starts the GUI and blocks until the user quits. +func Run() error { + a := app.NewWithID("space.apointless.haul") + a.Settings().SetTheme(&haulTheme{base: theme.DefaultTheme()}) + newConnectWindow(a).showAndRun() + return nil +} + +// haulTheme keeps the high-contrast, low-light-friendly look haul had as a TUI: +// a near-black background, bright text, and a clearly visible selection. It also +// tightens padding, because a file manager lives or dies on how many rows fit on +// screen. +type haulTheme struct{ base fyne.Theme } + +var ( + bgDark = color.NRGBA{R: 0x11, G: 0x13, B: 0x18, A: 0xff} + panelDark = color.NRGBA{R: 0x1a, G: 0x1d, B: 0x24, A: 0xff} + inputDark = color.NRGBA{R: 0x0b, G: 0x0d, B: 0x11, A: 0xff} + fgDark = color.NRGBA{R: 0xf0, G: 0xf3, B: 0xf8, A: 0xff} + mutedDark = color.NRGBA{R: 0x8b, G: 0x93, B: 0xa1, A: 0xff} + separatorDark = color.NRGBA{R: 0x2c, G: 0x31, B: 0x3a, A: 0xff} + accent = color.NRGBA{R: 0x4c, G: 0x9a, B: 0xff, A: 0xff} + selectionDark = color.NRGBA{R: 0x1e, G: 0x3f, B: 0x66, A: 0xff} + hoverDark = color.NRGBA{R: 0x25, G: 0x2a, B: 0x33, A: 0xff} +) + +func (t *haulTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { + if v == theme.VariantLight { + // In light mode only the accent is overridden; the stock light palette + // already has the contrast this theme is trying to buy back in the dark. + if n == theme.ColorNamePrimary { + return accent + } + return t.base.Color(n, v) + } + + switch n { + case theme.ColorNameBackground: + return bgDark + case theme.ColorNameForeground: + return fgDark + case theme.ColorNameButton, theme.ColorNameMenuBackground, theme.ColorNameOverlayBackground: + return panelDark + case theme.ColorNameHeaderBackground: + return panelDark + case theme.ColorNameInputBackground: + return inputDark + case theme.ColorNamePrimary: + return accent + case theme.ColorNameSelection: + return selectionDark + case theme.ColorNameHover: + return hoverDark + case theme.ColorNameSeparator: + return separatorDark + case theme.ColorNamePlaceHolder, theme.ColorNameDisabled: + return mutedDark + case theme.ColorNameScrollBar: + return separatorDark + } + return t.base.Color(n, v) +} + +func (t *haulTheme) Font(s fyne.TextStyle) fyne.Resource { return t.base.Font(s) } + +func (t *haulTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return t.base.Icon(n) } + +func (t *haulTheme) Size(n fyne.ThemeSizeName) float32 { + switch n { + case theme.SizeNamePadding: + return 3 // stock is 4; buys roughly one extra row per screenful + case theme.SizeNameInnerPadding: + return 6 + case theme.SizeNameSeparatorThickness: + return 1 + } + return t.base.Size(n) +} diff --git a/internal/vfs/vfs.go b/internal/vfs/vfs.go new file mode 100644 index 0000000..3e2314e --- /dev/null +++ b/internal/vfs/vfs.go @@ -0,0 +1,185 @@ +// Package vfs is the filesystem abstraction both sides of haul are driven +// through: the local disk and a remote SFTP connection look identical to the +// transfer engine (internal/xfer) and to the browser panes (internal/ui). +// +// The two differ in more than their I/O calls — local paths use the native +// separator and a platform-specific root, remote paths are always "/"-separated +// — so path manipulation is part of the interface rather than something callers +// are trusted to get right per side. +package vfs + +import ( + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/pkg/sftp" +) + +// FS is the filesystem surface haul needs. Read/write methods cover the copy +// engine; the path helpers and the mutating operations cover the browser. +type FS interface { + // Open opens a file for reading with seek support (needed to resume). + Open(p string) (io.ReadSeekCloser, error) + // Create truncates/creates a file for writing. + Create(p string) (io.WriteCloser, error) + // OpenAt opens an existing file for writing positioned at offset, used to + // resume a partial transfer. (SFTP O_APPEND is unreliable — pkg/sftp writes + // at an explicit offset — so we seek instead.) + OpenAt(p string, offset int64) (io.WriteCloser, error) + Stat(p string) (os.FileInfo, error) + ReadDir(p string) ([]os.FileInfo, error) + MkdirAll(p string) error + Join(elem ...string) string + Base(p string) string + + // Dir returns p's parent directory. + Dir(p string) string + // IsRoot reports whether p has no parent to ascend to. + IsRoot(p string) bool + // Home is the directory a pane opens at: the user's home, falling back to + // the working directory and then the root. + Home() string + // Mkdir creates a single directory. + Mkdir(p string) error + // Remove deletes a file, or a directory and everything under it. + Remove(p string) error + // Rename moves oldPath to newPath within this filesystem. + Rename(oldPath, newPath string) error +} + +// Local is the local disk, using os + filepath (native separators). +type Local struct{} + +func (Local) Open(p string) (io.ReadSeekCloser, error) { return os.Open(p) } +func (Local) Create(p string) (io.WriteCloser, error) { + return os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) +} +func (Local) OpenAt(p string, offset int64) (io.WriteCloser, error) { + f, err := os.OpenFile(p, os.O_WRONLY, 0o644) + if err != nil { + return nil, err + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + f.Close() + return nil, err + } + return f, nil +} +func (Local) Stat(p string) (os.FileInfo, error) { return os.Stat(p) } +func (Local) MkdirAll(p string) error { return os.MkdirAll(p, 0o755) } +func (Local) Join(elem ...string) string { return filepath.Join(elem...) } +func (Local) Base(p string) string { return filepath.Base(p) } +func (Local) ReadDir(p string) ([]os.FileInfo, error) { return readDirInfos(p) } +func (Local) Dir(p string) string { return filepath.Dir(p) } + +// IsRoot is true when p is its own parent, which is how both "/" and "C:\" +// behave under filepath.Dir. +func (Local) IsRoot(p string) bool { return p == filepath.Dir(p) } + +func (l Local) Home() string { + if home, err := os.UserHomeDir(); err == nil { + return home + } + if wd, err := os.Getwd(); err == nil { + return wd + } + return string(filepath.Separator) +} + +func (Local) Mkdir(p string) error { return os.Mkdir(p, 0o755) } +func (Local) Remove(p string) error { return os.RemoveAll(p) } +func (Local) Rename(a, b string) error { return os.Rename(a, b) } + +func readDirInfos(p string) ([]os.FileInfo, error) { + entries, err := os.ReadDir(p) + if err != nil { + return nil, err + } + out := make([]os.FileInfo, 0, len(entries)) + for _, e := range entries { + fi, err := e.Info() + if err != nil { + // A file that vanished between the listing and the stat isn't an + // error worth failing the whole directory over. + continue + } + out = append(out, fi) + } + return out, nil +} + +// Remote is the SFTP side, using the "/"-separated path package. +type Remote struct{ C *sftp.Client } + +func (r Remote) Open(p string) (io.ReadSeekCloser, error) { return r.C.Open(p) } +func (r Remote) Create(p string) (io.WriteCloser, error) { + return r.C.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC) +} +func (r Remote) OpenAt(p string, offset int64) (io.WriteCloser, error) { + f, err := r.C.OpenFile(p, os.O_WRONLY) + if err != nil { + return nil, err + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + f.Close() + return nil, err + } + return f, nil +} +func (r Remote) Stat(p string) (os.FileInfo, error) { return r.C.Stat(p) } +func (r Remote) MkdirAll(p string) error { return r.C.MkdirAll(p) } +func (r Remote) Join(elem ...string) string { return path.Join(elem...) } +func (r Remote) Base(p string) string { return path.Base(p) } +func (r Remote) ReadDir(p string) ([]os.FileInfo, error) { return r.C.ReadDir(p) } +func (r Remote) Dir(p string) string { return path.Dir(p) } +func (r Remote) IsRoot(p string) bool { return p == "/" || p == "" } + +// Home asks the server where it put us; the SFTP subsystem starts in the +// account's home directory, so Getwd is the answer without shelling out. +func (r Remote) Home() string { + if wd, err := r.C.Getwd(); err == nil && wd != "" { + return wd + } + return "/" +} + +func (r Remote) Mkdir(p string) error { return r.C.Mkdir(p) } +func (r Remote) Rename(a, b string) error { return r.C.Rename(a, b) } + +// Remove deletes p, recursing into directories: SFTP's RemoveDirectory only +// works on empty directories, so the tree has to be walked bottom-up. +func (r Remote) Remove(p string) error { + fi, err := r.C.Stat(p) + if err != nil { + return err + } + if !fi.IsDir() { + return r.C.Remove(p) + } + entries, err := r.C.ReadDir(p) + if err != nil { + return err + } + for _, e := range entries { + if err := r.Remove(path.Join(p, e.Name())); err != nil { + return err + } + } + return r.C.RemoveDirectory(p) +} + +// CleanPath normalises user-typed input for fs: trimming stray whitespace and +// collapsing "." and ".." segments, leaving a path fs can Stat. +func CleanPath(fs FS, p string) string { + p = strings.TrimSpace(p) + if p == "" { + return fs.Home() + } + if _, ok := fs.(Remote); ok { + return path.Clean(p) + } + return filepath.Clean(p) +} diff --git a/internal/xfer/xfer.go b/internal/xfer/xfer.go new file mode 100644 index 0000000..e910600 --- /dev/null +++ b/internal/xfer/xfer.go @@ -0,0 +1,268 @@ +// Package xfer implements haul's SFTP file-transfer engine: recursive push/pull +// with aggregate progress, per-file overwrite policy, and same-session resume. +// +// Push (local→remote) and pull (remote→local) share one recursive copy routine +// by abstracting each side behind vfs.FS, so the tree-walking, resume, and +// conflict logic is written once and exercised in both directions. +package xfer + +import ( + "errors" + "fmt" + "io" + "os" + "sync/atomic" + + "github.com/pkg/sftp" + + "gitea.apointless.space/bsncubed/haul/internal/vfs" +) + +// ErrCanceled is returned by Push/Pull when Cancel was called mid-transfer. +// Files already copied are left in place; the partial file is left on disk so +// that re-running the same transfer on this Engine resumes it. +var ErrCanceled = errors.New("transfer canceled") + +// Conflict is the decision for a destination file that already existed before +// this transfer began. +type Conflict int + +const ( + Overwrite Conflict = iota // replace the existing file + Skip // leave the existing file, skip the source + Rename // write the source under a non-colliding name +) + +// Progress is a snapshot of an in-flight transfer, reported to OnProgress. +type Progress struct { + CurrentName string // file currently copying + FilesDone int + FilesTotal int + BytesDone int64 // cumulative across all files + BytesTotal int64 +} + +// ConflictFunc decides what to do about a pre-existing destination file. name +// is the destination path. Returning the same decision for every call is the +// "apply to all" behaviour. +type ConflictFunc func(name string) Conflict + +// ProgressFunc receives periodic progress updates. It must not block. +type ProgressFunc func(Progress) + +// Engine performs transfers over one SFTP connection. A single Engine instance +// remembers which destination files it has created this session, which is what +// makes same-session resume work: re-running an interrupted transfer resumes +// partial files it started rather than treating them as pre-existing conflicts. +type Engine struct { + remote vfs.FS + OnProgress ProgressFunc + OnConflict ConflictFunc + + created map[string]bool // destination paths this session started/finished + canceled atomic.Bool // set by Cancel, cleared at the start of each run +} + +// New returns an Engine bound to an SFTP client. +func New(c *sftp.Client) *Engine { + return &Engine{remote: vfs.Remote{C: c}, created: map[string]bool{}} +} + +// Cancel asks an in-flight transfer to stop. It is safe to call from another +// goroutine (the GUI's stop button) and takes effect at the next file or write +// boundary; Push/Pull then return ErrCanceled. +func (e *Engine) Cancel() { e.canceled.Store(true) } + +// Push copies local sources into the remote directory (recursively for dirs). +func (e *Engine) Push(localSources []string, remoteDir string) error { + return e.run(vfs.Local{}, localSources, e.remote, remoteDir) +} + +// Pull copies remote sources into the local directory (recursively for dirs). +func (e *Engine) Pull(remoteSources []string, localDir string) error { + return e.run(e.remote, remoteSources, vfs.Local{}, localDir) +} + +func (e *Engine) run(src vfs.FS, sources []string, dst vfs.FS, dstDir string) error { + e.canceled.Store(false) + prog := Progress{} + // First pass: count files and total bytes for aggregate progress. + for _, s := range sources { + n, bytes, err := countTree(src, s) + if err != nil { + return err + } + prog.FilesTotal += n + prog.BytesTotal += bytes + } + if err := dst.MkdirAll(dstDir); err != nil { + return err + } + for _, s := range sources { + target := dst.Join(dstDir, src.Base(s)) + if err := e.copyTree(src, s, dst, target, &prog); err != nil { + return err + } + } + return nil +} + +// countTree returns the file count and total byte size under p. +func countTree(src vfs.FS, p string) (int, int64, error) { + fi, err := src.Stat(p) + if err != nil { + return 0, 0, err + } + if !fi.IsDir() { + return 1, fi.Size(), nil + } + entries, err := src.ReadDir(p) + if err != nil { + return 0, 0, err + } + var files int + var bytes int64 + for _, ent := range entries { + n, b, err := countTree(src, src.Join(p, ent.Name())) + if err != nil { + return 0, 0, err + } + files += n + bytes += b + } + return files, bytes, nil +} + +func (e *Engine) copyTree(src vfs.FS, srcPath string, dst vfs.FS, dstPath string, prog *Progress) error { + if e.canceled.Load() { + return ErrCanceled + } + fi, err := src.Stat(srcPath) + if err != nil { + return err + } + if fi.IsDir() { + if err := dst.MkdirAll(dstPath); err != nil { + return err + } + entries, err := src.ReadDir(srcPath) + if err != nil { + return err + } + for _, ent := range entries { + child := dst.Join(dstPath, ent.Name()) + if err := e.copyTree(src, src.Join(srcPath, ent.Name()), dst, child, prog); err != nil { + return err + } + } + return nil + } + return e.copyFile(src, srcPath, fi.Size(), dst, dstPath, prog) +} + +// copyFile copies a single regular file, honouring conflict policy for +// pre-existing destinations and resuming partials this session started. +func (e *Engine) copyFile(src vfs.FS, srcPath string, srcSize int64, dst vfs.FS, dstPath string, prog *Progress) error { + var offset int64 + + if info, err := dst.Stat(dstPath); err == nil { + if e.created[dstPath] { + // Our own partial from an interrupted attempt: resume from its end. + if info.Size() <= srcSize { + offset = info.Size() + } + } else { + // Pre-existing file: ask the caller what to do. + switch e.conflict(dstPath) { + case Skip: + prog.FilesDone++ + prog.BytesDone += srcSize + e.report(prog, dst.Base(dstPath)) + return nil + case Rename: + dstPath = uniqueName(dst, dstPath) + case Overwrite: + // fall through; Create truncates. + } + } + } + + in, err := src.Open(srcPath) + if err != nil { + return err + } + defer in.Close() + + var out io.WriteCloser + if offset > 0 { + if _, err := in.Seek(offset, io.SeekStart); err != nil { + return err + } + if out, err = dst.OpenAt(dstPath, offset); err != nil { + return err + } + } else if out, err = dst.Create(dstPath); err != nil { + return err + } + e.created[dstPath] = true + + prog.BytesDone += offset // resumed bytes already count as done + pw := &progressWriter{w: out, prog: prog, name: dst.Base(dstPath), report: e.report, canceled: &e.canceled} + if _, err := io.Copy(pw, in); err != nil { + out.Close() + return fmt.Errorf("copying %s: %w", srcPath, err) + } + if err := out.Close(); err != nil { + return err + } + prog.FilesDone++ + e.report(prog, dst.Base(dstPath)) + return nil +} + +func (e *Engine) conflict(name string) Conflict { + if e.OnConflict == nil { + return Overwrite + } + return e.OnConflict(name) +} + +func (e *Engine) report(prog *Progress, name string) { + if e.OnProgress == nil { + return + } + p := *prog + p.CurrentName = name + e.OnProgress(p) +} + +// uniqueName returns dstPath with a numeric suffix that doesn't yet exist. +func uniqueName(dst vfs.FS, dstPath string) string { + for i := 1; ; i++ { + cand := fmt.Sprintf("%s.%d", dstPath, i) + if _, err := dst.Stat(cand); errors.Is(err, os.ErrNotExist) || err != nil { + return cand + } + } +} + +// progressWriter tallies bytes as they're written and emits progress updates. +// It is also where a cancel lands mid-file, so a single large file can be +// interrupted rather than only being stoppable between files. +type progressWriter struct { + w io.Writer + prog *Progress + name string + report func(*Progress, string) + canceled *atomic.Bool +} + +func (p *progressWriter) Write(b []byte) (int, error) { + if p.canceled != nil && p.canceled.Load() { + return 0, ErrCanceled + } + n, err := p.w.Write(b) + p.prog.BytesDone += int64(n) + p.report(p.prog, p.name) + return n, err +} diff --git a/internal/xfer/xfer_test.go b/internal/xfer/xfer_test.go new file mode 100644 index 0000000..57565d7 --- /dev/null +++ b/internal/xfer/xfer_test.go @@ -0,0 +1,160 @@ +package xfer + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/pkg/sftp" +) + +// fakeConn adapts two pipes into an io.ReadWriteCloser for the SFTP server. +type fakeConn struct { + io.Reader + io.WriteCloser +} + +// newTestEngine spins up an in-memory SFTP server over pipes and returns an +// Engine talking to it, so transfers can be exercised without a real sshd. +func newTestEngine(t *testing.T) *Engine { + t.Helper() + // OS pipes (kernel-buffered) avoid the handshake deadlock that unbuffered + // io.Pipe causes between the SFTP client and server. + srvR, cliW, err := os.Pipe() // client writes -> server reads + if err != nil { + t.Fatal(err) + } + cliR, srvW, err := os.Pipe() // server writes -> client reads + if err != nil { + t.Fatal(err) + } + + server, err := sftp.NewServer(fakeConn{srvR, srvW}) + if err != nil { + t.Fatal(err) + } + go server.Serve() + + client, err := sftp.NewClientPipe(cliR, cliW) + if err != nil { + t.Fatal(err) + } + // Close the server first so the client's recv goroutine sees EOF and + // client.Close() can return (otherwise the two block each other). + t.Cleanup(func() { server.Close(); client.Close() }) + return New(client) +} + +func write(t *testing.T, p, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func read(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestPushPullRecursive(t *testing.T) { + e := newTestEngine(t) + local := t.TempDir() + remote := t.TempDir() + + // A directory tree on the local side. + write(t, filepath.Join(local, "proj", "a.txt"), "alpha") + write(t, filepath.Join(local, "proj", "sub", "b.txt"), "bravo") + + var last Progress + e.OnProgress = func(p Progress) { last = p } + + if err := e.Push([]string{filepath.Join(local, "proj")}, remote); err != nil { + t.Fatal(err) + } + if got := read(t, filepath.Join(remote, "proj", "a.txt")); got != "alpha" { + t.Errorf("a.txt = %q", got) + } + if got := read(t, filepath.Join(remote, "proj", "sub", "b.txt")); got != "bravo" { + t.Errorf("b.txt = %q", got) + } + if last.FilesDone != 2 || last.FilesTotal != 2 { + t.Errorf("files done/total = %d/%d, want 2/2", last.FilesDone, last.FilesTotal) + } + if last.BytesDone != last.BytesTotal || last.BytesTotal != 10 { + t.Errorf("bytes done/total = %d/%d, want 10/10", last.BytesDone, last.BytesTotal) + } + + // Pull it back into a fresh local dir. + local2 := t.TempDir() + if err := e.Pull([]string{filepath.Join(remote, "proj")}, local2); err != nil { + t.Fatal(err) + } + if got := read(t, filepath.Join(local2, "proj", "sub", "b.txt")); got != "bravo" { + t.Errorf("pulled b.txt = %q", got) + } +} + +func TestResumePartial(t *testing.T) { + e := newTestEngine(t) + local := t.TempDir() + remote := t.TempDir() + + write(t, filepath.Join(local, "big.txt"), "hello world") // 11 bytes + // Simulate an interrupted prior attempt: a 5-byte partial that THIS engine + // created, so it should resume (append) rather than restart or conflict. + dst := filepath.Join(remote, "big.txt") + write(t, dst, "hello") + e.created[dst] = true + + if err := e.Push([]string{filepath.Join(local, "big.txt")}, remote); err != nil { + t.Fatal(err) + } + if got := read(t, dst); got != "hello world" { + t.Errorf("resume result = %q, want 'hello world'", got) + } +} + +func TestConflictPolicies(t *testing.T) { + cases := []struct { + name string + policy Conflict + wantDst string // content at original dst path + wantCopy string // content at dst.1 (only for Rename), "" if none + }{ + {"skip", Skip, "OLD", ""}, + {"overwrite", Overwrite, "NEW", ""}, + {"rename", Rename, "OLD", "NEW"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := newTestEngine(t) + local := t.TempDir() + remote := t.TempDir() + write(t, filepath.Join(local, "f.txt"), "NEW") + dst := filepath.Join(remote, "f.txt") + write(t, dst, "OLD") // pre-existing, engine did NOT create it + e.OnConflict = func(string) Conflict { return tc.policy } + + if err := e.Push([]string{filepath.Join(local, "f.txt")}, remote); err != nil { + t.Fatal(err) + } + if got := read(t, dst); got != tc.wantDst { + t.Errorf("dst = %q, want %q", got, tc.wantDst) + } + if tc.wantCopy != "" { + if got := read(t, dst+".1"); got != tc.wantCopy { + t.Errorf("renamed copy = %q, want %q", got, tc.wantCopy) + } + } + }) + } +}