Eric Bower
·
2026-06-17
1package pgs
2
3import (
4 "encoding/pem"
5 "fmt"
6 "io"
7 "log/slog"
8 "net"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
17 "github.com/picosh/pico/pkg/db"
18 "github.com/picosh/pico/pkg/pssh"
19 "github.com/picosh/pico/pkg/shared"
20 "github.com/picosh/pico/pkg/storage"
21 "github.com/pkg/sftp"
22 "github.com/prometheus/client_golang/prometheus"
23)
24
25// TestRsyncDeleteDirectoryWithKeepDir verifies that rsync --delete can
26// successfully delete a directory that contains . _pico_keep_dir markers.
27//
28// Regression test for: "remove /storage/.../project/... directory not empty"
29// The bug occurred because . _pico_keep_dir files were not cleaned up when
30// their parent directory was explicitly deleted, causing os.Remove() to fail.
31func TestRsyncDeleteDirectoryWithKeepDir(t *testing.T) {
32 opts := &slog.HandlerOptions{
33 AddSource: true,
34 Level: slog.LevelDebug,
35 }
36 logger := slog.New(
37 slog.NewTextHandler(os.Stdout, opts),
38 )
39 slog.SetDefault(logger)
40 dbpool := pgsdb.NewDBMemory(logger)
41 dbpool.SetupTestData()
42
43 // Use filesystem storage so the "directory not empty" bug manifests
44 tmpDir, err := os.MkdirTemp("", "pgs-test-storage-*")
45 if err != nil {
46 t.Fatalf("failed to create temp dir: %v", err)
47 }
48 defer func() {
49 _ = os.RemoveAll(tmpDir)
50 }()
51
52 st, err := storage.NewStorageFS(logger, tmpDir)
53 if err != nil {
54 t.Fatalf("failed to create storage: %v", err)
55 }
56
57 pubsub := NewPubsubChan()
58 defer func() {
59 _ = pubsub.Close()
60 }()
61
62 _ = os.Setenv("PGS_SSH_PORT", "0")
63 _ = os.Setenv("PGS_PROM_PORT", "0")
64
65 cfg := NewPgsConfig(logger, dbpool, st, pubsub)
66 done := make(chan error)
67 readyCh := make(chan *pssh.SSHServer)
68 prometheus.DefaultRegisterer = prometheus.NewRegistry()
69
70 go StartSshServerForTesting(cfg, done, readyCh)
71
72 server := <-readyCh
73 if server == nil {
74 t.Fatal("failed to create ssh server")
75 }
76
77 var actualAddr string
78 for i := 0; i < 100; i++ {
79 server.Mu.Lock()
80 listener := server.Listener
81 server.Mu.Unlock()
82 if listener != nil {
83 actualAddr = listener.Addr().String()
84 break
85 }
86 time.Sleep(10 * time.Millisecond)
87 }
88 if actualAddr == "" {
89 t.Fatal("server listener not ready")
90 }
91
92 user := GenerateUser()
93 dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
94 ID: "test-pubkey-keepdir",
95 UserID: dbpool.Users[0].ID,
96 Key: shared.KeyForKeyText(user.signer.PublicKey()),
97 })
98
99 conn, err := user.NewClientAddr(actualAddr)
100 if err != nil {
101 t.Fatalf("failed to connect: %v", err)
102 }
103 defer func() {
104 _ = conn.Close()
105 }()
106
107 client, err := sftp.NewClient(conn)
108 if err != nil {
109 t.Fatalf("failed to create sftp client: %v", err)
110 }
111 defer func() {
112 _ = client.Close()
113 }()
114
115 // Create temp directory with nested structure
116 name, err := os.MkdirTemp("", "rsync-dir-test-*")
117 if err != nil {
118 t.Fatalf("failed to create temp dir: %v", err)
119 }
120 defer func() {
121 _ = os.RemoveAll(name)
122 }()
123
124 // Create: project/subdir/nested/file.txt
125 nestedDir := filepath.Join(name, "subdir", "nested")
126 err = os.MkdirAll(nestedDir, 0755)
127 if err != nil {
128 t.Fatalf("failed to create nested dir: %v", err)
129 }
130 err = os.WriteFile(filepath.Join(nestedDir, "deep.txt"), []byte("deep content"), 0644)
131 if err != nil {
132 t.Fatalf("failed to write deep.txt: %v", err)
133 }
134
135 // Create: project/subdir/file.txt
136 err = os.WriteFile(filepath.Join(name, "subdir", "file.txt"), []byte("subdir content"), 0644)
137 if err != nil {
138 t.Fatalf("failed to write file.txt: %v", err)
139 }
140
141 // Create: project/index.html (stays after delete)
142 err = os.WriteFile(filepath.Join(name, "index.html"), []byte("index content"), 0644)
143 if err != nil {
144 t.Fatalf("failed to write index.html: %v", err)
145 }
146
147 block := &pem.Block{
148 Type: "OPENSSH PRIVATE KEY",
149 Bytes: user.privateKey,
150 }
151 keyFile := filepath.Join(name, "id_ed25519")
152 err = os.WriteFile(keyFile, pem.EncodeToMemory(block), 0600)
153 if err != nil {
154 t.Fatalf("failed to write key file: %v", err)
155 }
156
157 _, port, err := net.SplitHostPort(actualAddr)
158 if err != nil {
159 t.Fatalf("failed to parse server address: %v", err)
160 }
161
162 eCmd := fmt.Sprintf(
163 "ssh -p %s -o IdentitiesOnly=yes -i %s -o StrictHostKeyChecking=no",
164 port, keyFile,
165 )
166
167 // Upload files including the subdir/ directory
168 cmd := exec.Command("rsync", "-rv", "-e", eCmd, name+"/", "localhost:/testdir")
169 result, err := cmd.CombinedOutput()
170 if err != nil {
171 t.Fatalf("rsync upload failed: %v\noutput: %s", err, result)
172 }
173
174 // Verify files exist on the server
175 _, err = client.Lstat("/testdir/subdir/file.txt")
176 if err != nil {
177 t.Fatalf("subdir/file.txt not found after upload: %v", err)
178 }
179 _, err = client.Lstat("/testdir/subdir/nested/deep.txt")
180 if err != nil {
181 t.Fatalf("subdir/nested/deep.txt not found after upload: %v", err)
182 }
183
184 // Now remove the entire subdir/ from the local directory
185 err = os.RemoveAll(filepath.Join(name, "subdir"))
186 if err != nil {
187 t.Fatalf("failed to remove local subdir: %v", err)
188 }
189
190 // Run rsync --delete - this should delete the subdir/ directory
191 // WITHOUT failing with "directory not empty"
192 delCmd := exec.Command("rsync", "-rv", "--delete", "-e", eCmd, name+"/", "localhost:/testdir")
193 result, err = delCmd.CombinedOutput()
194 if err != nil {
195 t.Fatalf("rsync --delete failed (this was the bug - 'directory not empty'): %v\noutput: %s", err, result)
196 }
197
198 // Verify the subdir and its contents are gone
199 _, err = client.Lstat("/testdir/subdir")
200 if err == nil {
201 t.Fatal("subdir/ should have been deleted but still exists")
202 }
203 // SFTP can return "no such file" or "file does not exist" depending on version
204 if !strings.Contains(err.Error(), "no such file") && !strings.Contains(err.Error(), "does not exist") {
205 t.Fatalf("expected 'not found' error, got: %v", err)
206 }
207
208 // Verify the . _pico_keep_dir markers are also cleaned up
209 _, err = client.Lstat("/testdir/subdir/._pico_keep_dir")
210 if err == nil {
211 t.Fatal("subdir/._pico_keep_dir should have been deleted but still exists")
212 }
213
214 // Verify index.html still exists (it wasn't deleted)
215 fi, err := client.Lstat("/testdir/index.html")
216 if err != nil {
217 t.Fatalf("index.html should still exist: %v", err)
218 }
219 if fi.Size() != 13 {
220 t.Errorf("index.html has wrong size: got %d, want 13", fi.Size())
221 }
222
223 close(done)
224 time.Sleep(100 * time.Millisecond)
225}
226
227// TestRsyncDeleteNestedEmptyDirectories verifies that rsync --delete handles
228// deeply nested directory structures where intermediate directories become empty
229// and need . _pico_keep_dir markers managed correctly.
230func TestRsyncDeleteNestedEmptyDirectories(t *testing.T) {
231 opts := &slog.HandlerOptions{
232 AddSource: true,
233 Level: slog.LevelDebug,
234 }
235 logger := slog.New(
236 slog.NewTextHandler(io.Discard, opts),
237 )
238 slog.SetDefault(logger)
239 dbpool := pgsdb.NewDBMemory(logger)
240 dbpool.SetupTestData()
241
242 tmpDir, err := os.MkdirTemp("", "pgs-test-nested-*")
243 if err != nil {
244 t.Fatalf("failed to create temp dir: %v", err)
245 }
246 defer func() {
247 _ = os.RemoveAll(tmpDir)
248 }()
249
250 st, err := storage.NewStorageFS(logger, tmpDir)
251 if err != nil {
252 t.Fatalf("failed to create storage: %v", err)
253 }
254
255 pubsub := NewPubsubChan()
256 defer func() {
257 _ = pubsub.Close()
258 }()
259
260 _ = os.Setenv("PGS_SSH_PORT", "0")
261 _ = os.Setenv("PGS_PROM_PORT", "0")
262
263 cfg := NewPgsConfig(logger, dbpool, st, pubsub)
264 done := make(chan error)
265 readyCh := make(chan *pssh.SSHServer)
266 prometheus.DefaultRegisterer = prometheus.NewRegistry()
267
268 go StartSshServerForTesting(cfg, done, readyCh)
269
270 server := <-readyCh
271 if server == nil {
272 t.Fatal("failed to create ssh server")
273 }
274
275 var actualAddr string
276 for i := 0; i < 100; i++ {
277 server.Mu.Lock()
278 listener := server.Listener
279 server.Mu.Unlock()
280 if listener != nil {
281 actualAddr = listener.Addr().String()
282 break
283 }
284 time.Sleep(10 * time.Millisecond)
285 }
286 if actualAddr == "" {
287 t.Fatal("server listener not ready")
288 }
289
290 user := GenerateUser()
291 dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
292 ID: "test-pubkey-nested",
293 UserID: dbpool.Users[0].ID,
294 Key: shared.KeyForKeyText(user.signer.PublicKey()),
295 })
296
297 conn, err := user.NewClientAddr(actualAddr)
298 if err != nil {
299 t.Fatalf("failed to connect: %v", err)
300 }
301 defer func() {
302 _ = conn.Close()
303 }()
304
305 client, err := sftp.NewClient(conn)
306 if err != nil {
307 t.Fatalf("failed to create sftp client: %v", err)
308 }
309 defer func() {
310 _ = client.Close()
311 }()
312
313 // Create temp directory with deeply nested structure
314 name, err := os.MkdirTemp("", "rsync-nested-test-*")
315 if err != nil {
316 t.Fatalf("failed to create temp dir: %v", err)
317 }
318 defer func() {
319 _ = os.RemoveAll(name)
320 }()
321
322 // Create: a/b/c/d/file.txt
323 deepDir := filepath.Join(name, "a", "b", "c", "d")
324 err = os.MkdirAll(deepDir, 0755)
325 if err != nil {
326 t.Fatalf("failed to create deep dir: %v", err)
327 }
328 err = os.WriteFile(filepath.Join(deepDir, "file.txt"), []byte("deep"), 0644)
329 if err != nil {
330 t.Fatalf("failed to write file: %v", err)
331 }
332
333 block := &pem.Block{
334 Type: "OPENSSH PRIVATE KEY",
335 Bytes: user.privateKey,
336 }
337 keyFile := filepath.Join(name, "id_ed25519")
338 err = os.WriteFile(keyFile, pem.EncodeToMemory(block), 0600)
339 if err != nil {
340 t.Fatalf("failed to write key file: %v", err)
341 }
342
343 _, port, err := net.SplitHostPort(actualAddr)
344 if err != nil {
345 t.Fatalf("failed to parse server address: %v", err)
346 }
347
348 eCmd := fmt.Sprintf(
349 "ssh -p %s -o IdentitiesOnly=yes -i %s -o StrictHostKeyChecking=no",
350 port, keyFile,
351 )
352
353 // Upload
354 cmd := exec.Command("rsync", "-rv", "-e", eCmd, name+"/", "localhost:/deep")
355 result, err := cmd.CombinedOutput()
356 if err != nil {
357 t.Fatalf("rsync upload failed: %v\noutput: %s", err, result)
358 }
359
360 // Remove the entire a/ directory locally
361 err = os.RemoveAll(filepath.Join(name, "a"))
362 if err != nil {
363 t.Fatalf("failed to remove local a/: %v", err)
364 }
365
366 // rsync --delete should handle the deeply nested structure
367 delCmd := exec.Command("rsync", "-rv", "--delete", "-e", eCmd, name+"/", "localhost:/deep")
368 result, err = delCmd.CombinedOutput()
369 if err != nil {
370 t.Fatalf("rsync --delete failed on nested dirs: %v\noutput: %s", err, result)
371 }
372
373 // Verify the entire tree is gone
374 _, err = client.Lstat("/deep/a")
375 if err == nil {
376 t.Fatal("/deep/a should have been deleted but still exists")
377 }
378
379 close(done)
380 time.Sleep(100 * time.Millisecond)
381}