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}