repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / send / protocols / rsync
Eric Bower  ·  2025-12-18

rsync_test.go

  1package rsync
  2
  3import (
  4	"bytes"
  5	"context"
  6	"io"
  7	"io/fs"
  8	"log/slog"
  9	"os"
 10	"testing"
 11	"time"
 12
 13	rsyncutils "github.com/picosh/go-rsync-receiver/utils"
 14	"github.com/picosh/pico/pkg/pssh"
 15	"github.com/picosh/pico/pkg/send/utils"
 16	"golang.org/x/crypto/ssh"
 17)
 18
 19// mockFileInfo implements fs.FileInfo for testing.
 20type mockFileInfo struct {
 21	name    string
 22	size    int64
 23	mode    fs.FileMode
 24	modTime time.Time
 25	isDir   bool
 26}
 27
 28func (m *mockFileInfo) Name() string       { return m.name }
 29func (m *mockFileInfo) Size() int64        { return m.size }
 30func (m *mockFileInfo) Mode() fs.FileMode  { return m.mode }
 31func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
 32func (m *mockFileInfo) IsDir() bool        { return m.isDir }
 33func (m *mockFileInfo) Sys() any           { return nil }
 34
 35// mockWriteHandler implements utils.CopyFromClientHandler for testing.
 36// It records the order of Delete calls to verify deletion order.
 37type mockWriteHandler struct {
 38	entries      []os.FileInfo
 39	deleteCalls  []string
 40	deleteErrors map[string]error
 41}
 42
 43func (m *mockWriteHandler) Delete(_ *pssh.SSHServerConnSession, entry *utils.FileEntry) error {
 44	m.deleteCalls = append(m.deleteCalls, entry.Filepath)
 45	if m.deleteErrors != nil {
 46		if err, ok := m.deleteErrors[entry.Filepath]; ok {
 47			return err
 48		}
 49	}
 50	return nil
 51}
 52
 53func (m *mockWriteHandler) Write(_ *pssh.SSHServerConnSession, _ *utils.FileEntry) (string, error) {
 54	return "", nil
 55}
 56
 57func (m *mockWriteHandler) Read(_ *pssh.SSHServerConnSession, _ *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error) {
 58	return nil, nil, nil
 59}
 60
 61func (m *mockWriteHandler) List(_ *pssh.SSHServerConnSession, _ string, _ bool, _ bool) ([]os.FileInfo, error) {
 62	return m.entries, nil
 63}
 64
 65func (m *mockWriteHandler) GetLogger(_ *pssh.SSHServerConnSession) *slog.Logger {
 66	return slog.Default()
 67}
 68
 69func (m *mockWriteHandler) Validate(_ *pssh.SSHServerConnSession) error {
 70	return nil
 71}
 72
 73// mockChannel implements ssh.Channel for testing.
 74type mockChannel struct {
 75	stderr *bytes.Buffer
 76}
 77
 78func (m *mockChannel) Read(_ []byte) (int, error)     { return 0, io.EOF }
 79func (m *mockChannel) Write(data []byte) (int, error) { return len(data), nil }
 80func (m *mockChannel) Close() error                   { return nil }
 81func (m *mockChannel) CloseWrite() error              { return nil }
 82func (m *mockChannel) SendRequest(_ string, _ bool, _ []byte) (bool, error) {
 83	return true, nil
 84}
 85func (m *mockChannel) Stderr() io.ReadWriter { return m.stderr }
 86
 87var _ ssh.Channel = (*mockChannel)(nil)
 88
 89// newMockSession creates a mock SSHServerConnSession for testing.
 90func newMockSession() (*pssh.SSHServerConnSession, *bytes.Buffer) {
 91	stderr := &bytes.Buffer{}
 92	channel := &mockChannel{stderr: stderr}
 93
 94	ctx, cancel := context.WithCancel(context.Background())
 95	logger := slog.Default()
 96	server := pssh.NewSSHServer(ctx, logger, &pssh.SSHServerConfig{})
 97	serverConn := pssh.NewSSHServerConn(ctx, logger, &ssh.ServerConn{
 98		Permissions: &ssh.Permissions{
 99			Extensions: map[string]string{},
100		},
101	}, server)
102
103	session := &pssh.SSHServerConnSession{
104		Channel:       channel,
105		SSHServerConn: serverConn,
106		Ctx:           ctx,
107		CancelFunc:    cancel,
108	}
109
110	return session, stderr
111}
112
113func TestRemove_DeletesChildrenBeforeParents(t *testing.T) {
114	session, _ := newMockSession()
115	mockHandler := &mockWriteHandler{
116		entries: []os.FileInfo{
117			&mockFileInfo{name: "a", isDir: true},
118			&mockFileInfo{name: "a/file.txt", size: 100},
119			&mockFileInfo{name: "b/c", isDir: true},
120			&mockFileInfo{name: "b/c/deep.txt", size: 50},
121			&mockFileInfo{name: "b", isDir: true},
122		},
123	}
124
125	h := &handler{
126		session:      session,
127		writeHandler: mockHandler,
128		root:         "test",
129	}
130
131	err := h.Remove([]*rsyncutils.ReceiverFile{})
132	if err != nil {
133		t.Fatalf("Remove() returned error: %v", err)
134	}
135
136	if len(mockHandler.deleteCalls) != 5 {
137		t.Fatalf("expected 5 delete calls, got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
138	}
139
140	indexOfA := -1
141	indexOfAFile := -1
142	indexOfB := -1
143	indexOfBC := -1
144	indexOfBCDeep := -1
145
146	for i, path := range mockHandler.deleteCalls {
147		switch path {
148		case "/test/a":
149			indexOfA = i
150		case "/test/a/file.txt":
151			indexOfAFile = i
152		case "/test/b":
153			indexOfB = i
154		case "/test/b/c":
155			indexOfBC = i
156		case "/test/b/c/deep.txt":
157			indexOfBCDeep = i
158		}
159	}
160
161	if indexOfAFile > indexOfA {
162		t.Errorf("a/file.txt (index %d) should be deleted before a (index %d)", indexOfAFile, indexOfA)
163	}
164	if indexOfBCDeep > indexOfBC {
165		t.Errorf("b/c/deep.txt (index %d) should be deleted before b/c (index %d)", indexOfBCDeep, indexOfBC)
166	}
167	if indexOfBC > indexOfB {
168		t.Errorf("b/c (index %d) should be deleted before b (index %d)", indexOfBC, indexOfB)
169	}
170}
171
172func TestRemove_IgnoresPicoKeepDir(t *testing.T) {
173	session, _ := newMockSession()
174	mockHandler := &mockWriteHandler{
175		entries: []os.FileInfo{
176			&mockFileInfo{name: "dir", isDir: true},
177			&mockFileInfo{name: "dir/._pico_keep_dir", size: 0},
178			&mockFileInfo{name: "dir/file.txt", size: 100},
179		},
180	}
181
182	h := &handler{
183		session:      session,
184		writeHandler: mockHandler,
185		root:         "test",
186	}
187
188	err := h.Remove([]*rsyncutils.ReceiverFile{})
189	if err != nil {
190		t.Fatalf("Remove() returned error: %v", err)
191	}
192
193	for _, path := range mockHandler.deleteCalls {
194		if path == "/test/dir/._pico_keep_dir" {
195			t.Error("._pico_keep_dir should not be in delete list")
196		}
197	}
198
199	if len(mockHandler.deleteCalls) != 2 {
200		t.Errorf("expected 2 delete calls (dir, dir/file.txt), got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
201	}
202}
203
204func TestRemove_OnlyDeletesFilesNotInWillReceive(t *testing.T) {
205	session, _ := newMockSession()
206	mockHandler := &mockWriteHandler{
207		entries: []os.FileInfo{
208			&mockFileInfo{name: "a.txt", size: 100},
209			&mockFileInfo{name: "b.txt", size: 100},
210			&mockFileInfo{name: "c.txt", size: 100},
211		},
212	}
213
214	h := &handler{
215		session:      session,
216		writeHandler: mockHandler,
217		root:         "test",
218	}
219
220	willReceive := []*rsyncutils.ReceiverFile{
221		{Name: "a.txt"},
222		{Name: "c.txt"},
223	}
224
225	err := h.Remove(willReceive)
226	if err != nil {
227		t.Fatalf("Remove() returned error: %v", err)
228	}
229
230	if len(mockHandler.deleteCalls) != 1 {
231		t.Fatalf("expected 1 delete call, got %d: %v", len(mockHandler.deleteCalls), mockHandler.deleteCalls)
232	}
233
234	if mockHandler.deleteCalls[0] != "/test/b.txt" {
235		t.Errorf("expected to delete /test/b.txt, got %s", mockHandler.deleteCalls[0])
236	}
237}