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