Files
haul/internal/xfer/xfer_test.go
T
bsncubed 6b52e83bdc 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 <noreply@anthropic.com>
2026-07-29 23:21:21 +10:00

161 lines
4.4 KiB
Go

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)
}
}
})
}
}