main pico / pkg / send / protocols / rsync / rsync_test.go
Antonio Mika  ·  2026-10-09
  1package rsync
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"io"
  8	"io/fs"
  9	"log/slog"
 10	"os"
 11	"slices"
 12	"strings"
 13	"testing"
 14	"time"
 15
 16	"github.com/picosh/pico/pkg/pssh"
 17	"github.com/picosh/pico/pkg/rsync"
 18	"github.com/picosh/pico/pkg/send/utils"
 19	"golang.org/x/crypto/ssh"
 20)
 21
 22// mockFileInfo implements fs.FileInfo for testing.
 23type mockFileInfo struct {
 24	name    string
 25	size    int64
 26	modTime time.Time
 27	isDir   bool
 28}
 29
 30func (m *mockFileInfo) Name() string { return m.name }
 31func (m *mockFileInfo) Size() int64  { return m.size }
 32func (m *mockFileInfo) Mode() fs.FileMode {
 33	if m.isDir {
 34		return fs.ModeDir | 0o755
 35	}
 36	return 0o644
 37}
 38func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
 39func (m *mockFileInfo) IsDir() bool        { return m.isDir }
 40func (m *mockFileInfo) Sys() any           { return nil }
 41
 42// mockWriteHandler implements utils.CopyFromClientHandler and records the
 43// paths it was called with.
 44type mockWriteHandler struct {
 45	lists   map[string][]os.FileInfo
 46	files   map[string]string
 47	calls   []string
 48	written map[string]string
 49}
 50
 51func (m *mockWriteHandler) Delete(_ *pssh.SSHServerConnSession, entry *utils.FileEntry) error {
 52	m.calls = append(m.calls, "delete "+entry.Filepath)
 53	return nil
 54}
 55
 56func (m *mockWriteHandler) Write(_ *pssh.SSHServerConnSession, entry *utils.FileEntry) (string, error) {
 57	m.calls = append(m.calls, "write "+entry.Filepath)
 58	b, err := io.ReadAll(entry.Reader)
 59	if err != nil {
 60		return "", err
 61	}
 62	if m.written == nil {
 63		m.written = map[string]string{}
 64	}
 65	m.written[entry.Filepath] = string(b)
 66	return "", nil
 67}
 68
 69func (m *mockWriteHandler) Read(_ *pssh.SSHServerConnSession, entry *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error) {
 70	m.calls = append(m.calls, "read "+entry.Filepath)
 71	data, ok := m.files[entry.Filepath]
 72	if !ok {
 73		return nil, nil, os.ErrNotExist
 74	}
 75	info := &mockFileInfo{name: entry.Filepath, size: int64(len(data))}
 76	return info, utils.NopReadAndReaderAtCloser(strings.NewReader(data)), nil
 77}
 78
 79func (m *mockWriteHandler) List(_ *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
 80	m.calls = append(m.calls, "list "+fpath)
 81	return m.lists[fpath], nil
 82}
 83
 84func (m *mockWriteHandler) GetLogger(_ *pssh.SSHServerConnSession) *slog.Logger {
 85	return slog.Default()
 86}
 87
 88func (m *mockWriteHandler) Validate(_ *pssh.SSHServerConnSession) error {
 89	return nil
 90}
 91
 92// mockChannel implements ssh.Channel for testing.
 93type mockChannel struct {
 94	stderr *bytes.Buffer
 95}
 96
 97func (m *mockChannel) Read(_ []byte) (int, error)     { return 0, io.EOF }
 98func (m *mockChannel) Write(data []byte) (int, error) { return len(data), nil }
 99func (m *mockChannel) Close() error                   { return nil }
100func (m *mockChannel) CloseWrite() error              { return nil }
101func (m *mockChannel) SendRequest(_ string, _ bool, _ []byte) (bool, error) {
102	return true, nil
103}
104func (m *mockChannel) Stderr() io.ReadWriter { return m.stderr }
105
106var _ ssh.Channel = (*mockChannel)(nil)
107
108// newMockSession creates a mock SSHServerConnSession for testing.
109func newMockSession() *pssh.SSHServerConnSession {
110	channel := &mockChannel{stderr: &bytes.Buffer{}}
111
112	ctx, cancel := context.WithCancel(context.Background())
113	logger := slog.Default()
114	server := pssh.NewSSHServer(ctx, logger, &pssh.SSHServerConfig{})
115	serverConn := pssh.NewSSHServerConn(ctx, logger, &ssh.ServerConn{
116		Permissions: &ssh.Permissions{
117			Extensions: map[string]string{},
118		},
119	}, server)
120
121	return &pssh.SSHServerConnSession{
122		Channel:       channel,
123		SSHServerConn: serverConn,
124		Ctx:           ctx,
125		CancelFunc:    cancel,
126	}
127}
128
129func TestReadDirNormalizesListing(t *testing.T) {
130	handler := &mockWriteHandler{lists: map[string][]os.FileInfo{
131		"/site": {
132			&mockFileInfo{name: "", isDir: true},
133			&mockFileInfo{name: "index.html", size: 10},
134			&mockFileInfo{name: "/css/site.css", size: 5},
135			&mockFileInfo{name: "css", isDir: true},
136			&mockFileInfo{name: "empty.txt"},
137		},
138	}}
139	fsys := &storageFS{session: newMockSession(), handler: handler}
140
141	entries, err := fsys.ReadDir("site", true)
142	if err != nil {
143		t.Fatal(err)
144	}
145	var names []string
146	for _, e := range entries {
147		names = append(names, e.Name)
148	}
149	want := []string{"index.html", "css/site.css", "css", "empty.txt"}
150	if !slices.Equal(names, want) {
151		t.Errorf("got %q, want %q", names, want)
152	}
153}
154
155func TestStat(t *testing.T) {
156	handler := &mockWriteHandler{lists: map[string][]os.FileInfo{
157		"/site":            {&mockFileInfo{name: "", isDir: true}},
158		"/site/index.html": {&mockFileInfo{name: "index.html", size: 42}},
159		"/":                {&mockFileInfo{name: "/", isDir: true}, &mockFileInfo{name: "hello.md"}},
160		"/posts":           {&mockFileInfo{name: "a.md"}, &mockFileInfo{name: "b.md"}},
161	}}
162	fsys := &storageFS{session: newMockSession(), handler: handler}
163
164	if info, err := fsys.Stat("site"); err != nil || !info.IsDir {
165		t.Errorf("site: %+v, %v", info, err)
166	}
167	if info, err := fsys.Stat("site/index.html"); err != nil || info.IsDir || info.Size != 42 {
168		t.Errorf("site/index.html: %+v, %v", info, err)
169	}
170	if info, err := fsys.Stat(""); err != nil || !info.IsDir {
171		t.Errorf("root: %+v, %v", info, err)
172	}
173	if info, err := fsys.Stat("posts"); err != nil || !info.IsDir {
174		t.Errorf("posts: %+v, %v", info, err)
175	}
176	if _, err := fsys.Stat("nope"); !errors.Is(err, fs.ErrNotExist) {
177		t.Errorf("nope: %v", err)
178	}
179}
180
181func TestHandlerPathsAreAbsolute(t *testing.T) {
182	handler := &mockWriteHandler{files: map[string]string{"/site/a.txt": "hello"}}
183	fsys := &storageFS{session: newMockSession(), handler: handler}
184
185	f, info, err := fsys.Open("site/a.txt")
186	if err != nil {
187		t.Fatal(err)
188	}
189	buf := make([]byte, info.Size)
190	if _, err := f.ReadAt(buf, 0); err != nil || string(buf) != "hello" {
191		t.Fatalf("read %q, %v", buf, err)
192	}
193	if _, err := fsys.Put("site/b.txt", fsInfo(5), strings.NewReader("world")); err != nil {
194		t.Fatal(err)
195	}
196	if err := fsys.Remove("site/c.txt", false); err != nil {
197		t.Fatal(err)
198	}
199	want := []string{"read /site/a.txt", "write /site/b.txt", "delete /site/c.txt"}
200	if !slices.Equal(handler.calls, want) {
201		t.Errorf("calls %q, want %q", handler.calls, want)
202	}
203	if handler.written["/site/b.txt"] != "world" {
204		t.Errorf("written %q", handler.written)
205	}
206}
207
208func fsInfo(size int64) rsync.FileInfo {
209	return rsync.FileInfo{Size: size, ModTime: time.Unix(1700000000, 0)}
210}