Antonio Mika
·
2026-10-11
1package pgs
2
3import (
4 "encoding/pem"
5 "fmt"
6 "io"
7 "io/fs"
8 "log/slog"
9 "net"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
18 "github.com/picosh/pico/pkg/db"
19 "github.com/picosh/pico/pkg/pssh"
20 "github.com/picosh/pico/pkg/shared"
21 "github.com/picosh/pico/pkg/storage"
22 "github.com/pkg/sftp"
23)
24
25// TestRsyncDeleteDirectory verifies that rsync --delete removes a directory
26// tree on filesystem storage without failing with "directory not empty".
27func TestRsyncDeleteDirectory(t *testing.T) {
28 opts := &slog.HandlerOptions{
29 AddSource: true,
30 Level: slog.LevelDebug,
31 }
32 logger := slog.New(
33 slog.NewTextHandler(os.Stdout, opts),
34 )
35 slog.SetDefault(logger)
36 dbpool := pgsdb.NewDBMemory(logger)
37 dbpool.SetupTestData()
38
39 // Use filesystem storage so the "directory not empty" bug manifests
40 tmpDir, err := os.MkdirTemp("", "pgs-test-storage-*")
41 if err != nil {
42 t.Fatalf("failed to create temp dir: %v", err)
43 }
44 defer func() {
45 _ = os.RemoveAll(tmpDir)
46 }()
47
48 st, err := storage.NewStorageFS(logger, tmpDir)
49 if err != nil {
50 t.Fatalf("failed to create storage: %v", err)
51 }
52
53 pubsub := NewPubsubChan()
54 defer func() {
55 _ = pubsub.Close()
56 }()
57
58 _ = os.Setenv("PGS_SSH_PORT", "0")
59
60 cfg := NewConfig(logger, dbpool, st, pubsub)
61 done := make(chan error)
62 readyCh := make(chan *pssh.SSHServer)
63
64 go StartSshServerForTesting(cfg, done, readyCh)
65
66 server := <-readyCh
67 if server == nil {
68 t.Fatal("failed to create ssh server")
69 }
70
71 var actualAddr string
72 for i := 0; i < 100; i++ {
73 server.Mu.Lock()
74 listener := server.Listener
75 server.Mu.Unlock()
76 if listener != nil {
77 actualAddr = listener.Addr().String()
78 break
79 }
80 time.Sleep(10 * time.Millisecond)
81 }
82 if actualAddr == "" {
83 t.Fatal("server listener not ready")
84 }
85
86 user := GenerateUser()
87 dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
88 ID: "test-pubkey-keepdir",
89 UserID: dbpool.Users[0].ID,
90 Key: shared.KeyForKeyText(user.signer.PublicKey()),
91 })
92
93 conn, err := user.NewClientAddr(actualAddr)
94 if err != nil {
95 t.Fatalf("failed to connect: %v", err)
96 }
97 defer func() {
98 _ = conn.Close()
99 }()
100
101 client, err := sftp.NewClient(conn)
102 if err != nil {
103 t.Fatalf("failed to create sftp client: %v", err)
104 }
105 defer func() {
106 _ = client.Close()
107 }()
108
109 // Create temp directory with nested structure
110 name, err := os.MkdirTemp("", "rsync-dir-test-*")
111 if err != nil {
112 t.Fatalf("failed to create temp dir: %v", err)
113 }
114 defer func() {
115 _ = os.RemoveAll(name)
116 }()
117
118 // Create: project/subdir/nested/file.txt
119 nestedDir := filepath.Join(name, "subdir", "nested")
120 err = os.MkdirAll(nestedDir, 0755)
121 if err != nil {
122 t.Fatalf("failed to create nested dir: %v", err)
123 }
124 err = os.WriteFile(filepath.Join(nestedDir, "deep.txt"), []byte("deep content"), 0644)
125 if err != nil {
126 t.Fatalf("failed to write deep.txt: %v", err)
127 }
128
129 // Create: project/subdir/file.txt
130 err = os.WriteFile(filepath.Join(name, "subdir", "file.txt"), []byte("subdir content"), 0644)
131 if err != nil {
132 t.Fatalf("failed to write file.txt: %v", err)
133 }
134
135 // Create: project/index.html (stays after delete)
136 err = os.WriteFile(filepath.Join(name, "index.html"), []byte("index content"), 0644)
137 if err != nil {
138 t.Fatalf("failed to write index.html: %v", err)
139 }
140
141 block := &pem.Block{
142 Type: "OPENSSH PRIVATE KEY",
143 Bytes: user.privateKey,
144 }
145 keyFile := filepath.Join(name, "id_ed25519")
146 err = os.WriteFile(keyFile, pem.EncodeToMemory(block), 0600)
147 if err != nil {
148 t.Fatalf("failed to write key file: %v", err)
149 }
150
151 _, port, err := net.SplitHostPort(actualAddr)
152 if err != nil {
153 t.Fatalf("failed to parse server address: %v", err)
154 }
155
156 eCmd := fmt.Sprintf(
157 "ssh -p %s -o IdentitiesOnly=yes -i %s -o StrictHostKeyChecking=no",
158 port, keyFile,
159 )
160
161 // Upload files including the subdir/ directory
162 cmd := exec.Command("rsync", "-rv", "-e", eCmd, name+"/", "localhost:/testdir")
163 result, err := cmd.CombinedOutput()
164 if err != nil {
165 t.Fatalf("rsync upload failed: %v\noutput: %s", err, result)
166 }
167
168 // Verify files exist on the server
169 _, err = client.Lstat("/testdir/subdir/file.txt")
170 if err != nil {
171 t.Fatalf("subdir/file.txt not found after upload: %v", err)
172 }
173 _, err = client.Lstat("/testdir/subdir/nested/deep.txt")
174 if err != nil {
175 t.Fatalf("subdir/nested/deep.txt not found after upload: %v", err)
176 }
177
178 // Now remove the entire subdir/ from the local directory
179 err = os.RemoveAll(filepath.Join(name, "subdir"))
180 if err != nil {
181 t.Fatalf("failed to remove local subdir: %v", err)
182 }
183
184 // Run rsync --delete - this should delete the subdir/ directory
185 // WITHOUT failing with "directory not empty"
186 delCmd := exec.Command("rsync", "-rv", "--delete", "-e", eCmd, name+"/", "localhost:/testdir")
187 result, err = delCmd.CombinedOutput()
188 if err != nil {
189 t.Fatalf("rsync --delete failed (this was the bug - 'directory not empty'): %v\noutput: %s", err, result)
190 }
191
192 // Verify the subdir and its contents are gone
193 _, err = client.Lstat("/testdir/subdir")
194 if err == nil {
195 t.Fatal("subdir/ should have been deleted but still exists")
196 }
197 // SFTP can return "no such file" or "file does not exist" depending on version
198 if !strings.Contains(err.Error(), "no such file") && !strings.Contains(err.Error(), "does not exist") {
199 t.Fatalf("expected 'not found' error, got: %v", err)
200 }
201
202 // Verify index.html still exists (it wasn't deleted)
203 fi, err := client.Lstat("/testdir/index.html")
204 if err != nil {
205 t.Fatalf("index.html should still exist: %v", err)
206 }
207 if fi.Size() != 13 {
208 t.Errorf("index.html has wrong size: got %d, want 13", fi.Size())
209 }
210
211 close(done)
212 time.Sleep(100 * time.Millisecond)
213}
214
215// TestRsyncDeleteNestedEmptyDirectories verifies that rsync --delete handles
216// deeply nested directory structures where intermediate directories become empty.
217func TestRsyncDeleteNestedEmptyDirectories(t *testing.T) {
218 opts := &slog.HandlerOptions{
219 AddSource: true,
220 Level: slog.LevelDebug,
221 }
222 logger := slog.New(
223 slog.NewTextHandler(io.Discard, opts),
224 )
225 slog.SetDefault(logger)
226 dbpool := pgsdb.NewDBMemory(logger)
227 dbpool.SetupTestData()
228
229 tmpDir, err := os.MkdirTemp("", "pgs-test-nested-*")
230 if err != nil {
231 t.Fatalf("failed to create temp dir: %v", err)
232 }
233 defer func() {
234 _ = os.RemoveAll(tmpDir)
235 }()
236
237 st, err := storage.NewStorageFS(logger, tmpDir)
238 if err != nil {
239 t.Fatalf("failed to create storage: %v", err)
240 }
241
242 pubsub := NewPubsubChan()
243 defer func() {
244 _ = pubsub.Close()
245 }()
246
247 _ = os.Setenv("PGS_SSH_PORT", "0")
248
249 cfg := NewConfig(logger, dbpool, st, pubsub)
250 done := make(chan error)
251 readyCh := make(chan *pssh.SSHServer)
252
253 go StartSshServerForTesting(cfg, done, readyCh)
254
255 server := <-readyCh
256 if server == nil {
257 t.Fatal("failed to create ssh server")
258 }
259
260 var actualAddr string
261 for i := 0; i < 100; i++ {
262 server.Mu.Lock()
263 listener := server.Listener
264 server.Mu.Unlock()
265 if listener != nil {
266 actualAddr = listener.Addr().String()
267 break
268 }
269 time.Sleep(10 * time.Millisecond)
270 }
271 if actualAddr == "" {
272 t.Fatal("server listener not ready")
273 }
274
275 user := GenerateUser()
276 dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
277 ID: "test-pubkey-nested",
278 UserID: dbpool.Users[0].ID,
279 Key: shared.KeyForKeyText(user.signer.PublicKey()),
280 })
281
282 conn, err := user.NewClientAddr(actualAddr)
283 if err != nil {
284 t.Fatalf("failed to connect: %v", err)
285 }
286 defer func() {
287 _ = conn.Close()
288 }()
289
290 client, err := sftp.NewClient(conn)
291 if err != nil {
292 t.Fatalf("failed to create sftp client: %v", err)
293 }
294 defer func() {
295 _ = client.Close()
296 }()
297
298 // Create temp directory with deeply nested structure
299 name, err := os.MkdirTemp("", "rsync-nested-test-*")
300 if err != nil {
301 t.Fatalf("failed to create temp dir: %v", err)
302 }
303 defer func() {
304 _ = os.RemoveAll(name)
305 }()
306
307 // Create: a/b/c/d/file.txt
308 deepDir := filepath.Join(name, "a", "b", "c", "d")
309 err = os.MkdirAll(deepDir, 0755)
310 if err != nil {
311 t.Fatalf("failed to create deep dir: %v", err)
312 }
313 err = os.WriteFile(filepath.Join(deepDir, "file.txt"), []byte("deep"), 0644)
314 if err != nil {
315 t.Fatalf("failed to write file: %v", err)
316 }
317
318 block := &pem.Block{
319 Type: "OPENSSH PRIVATE KEY",
320 Bytes: user.privateKey,
321 }
322 keyFile := filepath.Join(name, "id_ed25519")
323 err = os.WriteFile(keyFile, pem.EncodeToMemory(block), 0600)
324 if err != nil {
325 t.Fatalf("failed to write key file: %v", err)
326 }
327
328 _, port, err := net.SplitHostPort(actualAddr)
329 if err != nil {
330 t.Fatalf("failed to parse server address: %v", err)
331 }
332
333 eCmd := fmt.Sprintf(
334 "ssh -p %s -o IdentitiesOnly=yes -i %s -o StrictHostKeyChecking=no",
335 port, keyFile,
336 )
337
338 // Upload
339 cmd := exec.Command("rsync", "-rv", "-e", eCmd, name+"/", "localhost:/deep")
340 result, err := cmd.CombinedOutput()
341 if err != nil {
342 t.Fatalf("rsync upload failed: %v\noutput: %s", err, result)
343 }
344
345 // Remove the entire a/ directory locally
346 err = os.RemoveAll(filepath.Join(name, "a"))
347 if err != nil {
348 t.Fatalf("failed to remove local a/: %v", err)
349 }
350
351 // rsync --delete should handle the deeply nested structure
352 delCmd := exec.Command("rsync", "-rv", "--delete", "-e", eCmd, name+"/", "localhost:/deep")
353 result, err = delCmd.CombinedOutput()
354 if err != nil {
355 t.Fatalf("rsync --delete failed on nested dirs: %v\noutput: %s", err, result)
356 }
357
358 // Verify the entire tree is gone
359 _, err = client.Lstat("/deep/a")
360 if err == nil {
361 t.Fatal("/deep/a should have been deleted but still exists")
362 }
363
364 close(done)
365 time.Sleep(100 * time.Millisecond)
366}
367
368// setupPgsTestServer spins up an in-memory PGS SSH server for testing and
369// returns the server address and a teardown func.
370func setupPgsTestServer(t *testing.T, dbpool *pgsdb.MemoryDB) (string, func()) {
371 t.Helper()
372
373 opts := &slog.HandlerOptions{Level: slog.LevelDebug}
374 logger := slog.New(slog.NewTextHandler(io.Discard, opts))
375 slog.SetDefault(logger)
376
377 st, err := storage.NewStorageMemory(map[string]map[string]string{})
378 if err != nil {
379 t.Fatalf("failed to create storage: %v", err)
380 }
381
382 pubsub := NewPubsubChan()
383
384 _ = os.Setenv("PGS_SSH_PORT", "0")
385
386 cfg := NewConfig(logger, dbpool, st, pubsub)
387 done := make(chan error)
388 readyCh := make(chan *pssh.SSHServer)
389
390 go StartSshServerForTesting(cfg, done, readyCh)
391
392 server := <-readyCh
393 if server == nil {
394 t.Fatal("failed to create ssh server")
395 }
396
397 var actualAddr string
398 for i := 0; i < 100; i++ {
399 server.Mu.Lock()
400 listener := server.Listener
401 server.Mu.Unlock()
402 if listener != nil {
403 actualAddr = listener.Addr().String()
404 break
405 }
406 time.Sleep(10 * time.Millisecond)
407 }
408 if actualAddr == "" {
409 t.Fatal("server listener not ready")
410 }
411
412 teardown := func() {
413 _ = pubsub.Close()
414 close(done)
415 time.Sleep(100 * time.Millisecond)
416 }
417
418 return actualAddr, teardown
419}
420
421// TestPlusFlagAllowsUpload verifies that a user with a valid pico+ feature flag
422// can successfully upload files via SFTP.
423func TestPlusFlagAllowsUpload(t *testing.T) {
424 dbpool := pgsdb.NewDBMemory(slog.Default())
425 // SetupTestData creates a user with a valid (non-expired) plus feature flag.
426 dbpool.SetupTestData()
427
428 addr, teardown := setupPgsTestServer(t, dbpool)
429 defer teardown()
430
431 user := GenerateUser()
432 dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
433 ID: "plus-user-pubkey",
434 UserID: dbpool.Users[0].ID,
435 Key: shared.KeyForKeyText(user.signer.PublicKey()),
436 })
437
438 conn, err := user.NewClientAddr(addr)
439 if err != nil {
440 t.Fatalf("ssh dial failed: %v", err)
441 }
442 defer func() { _ = conn.Close() }()
443
444 client, err := sftp.NewClient(conn)
445 if err != nil {
446 t.Fatalf("sftp client failed: %v", err)
447 }
448 defer func() { _ = client.Close() }()
449
450 // Upload should succeed for a plus user.
451 f, err := client.Create("myproject/index.html")
452 if err != nil {
453 t.Fatalf("plus user upload was blocked unexpectedly: %v", err)
454 }
455 if _, err := f.Write([]byte("<html>hello</html>")); err != nil {
456 t.Fatalf("write failed: %v", err)
457 }
458 _ = f.Close()
459
460 // Confirm the file is accessible via SFTP stat.
461 fi, err := client.Lstat("myproject/index.html")
462 if err != nil {
463 t.Fatalf("file not found after upload: %v", err)
464 }
465 if fi.Size() == 0 {
466 t.Errorf("uploaded file has zero size")
467 }
468
469 // Confirm the project was created in the DB (no empty-project side-effect).
470 projects, err := dbpool.FindProjectsByUser(dbpool.Users[0].ID)
471 if err != nil {
472 t.Fatalf("FindProjectsByUser: %v", err)
473 }
474 found := false
475 for _, p := range projects {
476 if p.Name == "myproject" {
477 found = true
478 break
479 }
480 }
481 if !found {
482 t.Errorf("project 'myproject' was not created in the DB after upload")
483 }
484}
485
486// TestNoPlusFlagBlocksUpload verifies that a user whose pico+ subscription has
487// expired cannot upload files. The Validate() step (introduced in the last
488// commit) must reject the connection before any project is created, so we also
489// confirm that no empty project leaks into the DB.
490func TestNoPlusFlagBlocksUpload(t *testing.T) {
491 dbpool := pgsdb.NewDBMemory(slog.Default())
492 // Create a user but give them an *expired* plus feature flag.
493 dbpool.SetupTestData()
494 expired := time.Now().Add(-time.Hour)
495 dbpool.Feature.ExpiresAt = &expired // mark as expired
496
497 addr, teardown := setupPgsTestServer(t, dbpool)
498 defer teardown()
499
500 user := GenerateUser()
501 dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
502 ID: "no-plus-user-pubkey",
503 UserID: dbpool.Users[0].ID,
504 Key: shared.KeyForKeyText(user.signer.PublicKey()),
505 })
506
507 conn, err := user.NewClientAddr(addr)
508 if err != nil {
509 t.Fatalf("ssh dial failed: %v", err)
510 }
511 defer func() { _ = conn.Close() }()
512
513 // Validate() fires at the SFTP-subsystem session level, so the server may
514 // close the connection before the SFTP version handshake completes.
515 // Accept rejection at either sftp.NewClient or client.Create as valid.
516 client, sftpErr := sftp.NewClient(conn)
517 if sftpErr != nil {
518 // Server rejected the session during Validate() — this is the expected
519 // behavior for a non-plus user. Verify no project was leaked.
520 t.Logf("upload correctly blocked at SFTP handshake: %v", sftpErr)
521 } else {
522 defer func() { _ = client.Close() }()
523
524 // Upload must be rejected because the user's pico+ has expired.
525 _, err = client.Create("someproject/index.html")
526 if err == nil {
527 t.Fatal("expected upload to be blocked for non-plus user, but it succeeded")
528 }
529 t.Logf("upload correctly blocked at Create: %v", err)
530 }
531
532 // Most importantly: no empty project should have been created in the DB,
533 // because the refactored Validate() checks the feature flag *before*
534 // upserting the project bucket/quota.
535 projects, err := dbpool.FindProjectsByUser(dbpool.Users[0].ID)
536 if err != nil {
537 t.Fatalf("FindProjectsByUser: %v", err)
538 }
539 if len(projects) != 0 {
540 t.Errorf("expected 0 projects for non-plus user, got %d (empty project leak)", len(projects))
541 }
542}
543
544func TestFindFeatureFlag(t *testing.T) {
545 logger := slog.Default()
546 cfg := &Config{
547 MaxSize: uint64(100 * shared.MB),
548 MaxAssetSize: int64(10 * shared.MB),
549 MaxSpecialFileSize: int64(5 * shared.KB),
550 }
551
552 validExpires := time.Now().Add(24 * time.Hour)
553 expiredExpires := time.Now().Add(-24 * time.Hour)
554 userID := "user-1"
555
556 t.Run("plus valid returns plus", func(t *testing.T) {
557 dbpool := pgsdb.NewDBMemory(logger)
558 plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
559 plusFF.ExpiresAt = &validExpires
560 dbpool.Features = []*db.FeatureFlag{plusFF}
561
562 ff, err := findFeatureFlag(dbpool, cfg, userID)
563 if err != nil {
564 t.Fatalf("unexpected error: %v", err)
565 }
566 if ff.Name != "plus" {
567 t.Errorf("expected plus, got %s", ff.Name)
568 }
569 })
570
571 t.Run("pgs valid returns pgs when no plus", func(t *testing.T) {
572 dbpool := pgsdb.NewDBMemory(logger)
573 pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
574 pgsFF.ExpiresAt = &validExpires
575 dbpool.Features = []*db.FeatureFlag{pgsFF}
576
577 ff, err := findFeatureFlag(dbpool, cfg, userID)
578 if err != nil {
579 t.Fatalf("unexpected error: %v", err)
580 }
581 if ff.Name != "pgs" {
582 t.Errorf("expected pgs, got %s", ff.Name)
583 }
584 })
585
586 t.Run("plus preferred over pgs when both valid", func(t *testing.T) {
587 dbpool := pgsdb.NewDBMemory(logger)
588 plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
589 plusFF.ExpiresAt = &validExpires
590 pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
591 pgsFF.ExpiresAt = &validExpires
592 dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
593
594 ff, err := findFeatureFlag(dbpool, cfg, userID)
595 if err != nil {
596 t.Fatalf("unexpected error: %v", err)
597 }
598 if ff.Name != "plus" {
599 t.Errorf("expected plus to be picked first, got %s", ff.Name)
600 }
601 })
602
603 t.Run("pgs picked when plus is expired", func(t *testing.T) {
604 dbpool := pgsdb.NewDBMemory(logger)
605 plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
606 plusFF.ExpiresAt = &expiredExpires
607 pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
608 pgsFF.ExpiresAt = &validExpires
609 dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
610
611 ff, err := findFeatureFlag(dbpool, cfg, userID)
612 if err != nil {
613 t.Fatalf("unexpected error: %v", err)
614 }
615 if ff.Name != "pgs" {
616 t.Errorf("expected pgs when plus is expired, got %s", ff.Name)
617 }
618 })
619
620 t.Run("error when both plus and pgs expired", func(t *testing.T) {
621 dbpool := pgsdb.NewDBMemory(logger)
622 plusFF := db.NewFeatureFlag(userID, "plus", uint64(50*shared.MB), int64(5*shared.MB), int64(2*shared.KB))
623 plusFF.ExpiresAt = &expiredExpires
624 pgsFF := db.NewFeatureFlag(userID, "pgs", uint64(30*shared.MB), int64(3*shared.MB), int64(1*shared.KB))
625 pgsFF.ExpiresAt = &expiredExpires
626 dbpool.Features = []*db.FeatureFlag{plusFF, pgsFF}
627
628 _, err := findFeatureFlag(dbpool, cfg, userID)
629 if err == nil {
630 t.Fatal("expected error, got nil")
631 }
632 if !strings.Contains(err.Error(), "https://blog.pico.sh/ann-038-pico-invite-system") {
633 t.Errorf("expected blog link in error, got %v", err)
634 }
635 })
636
637 t.Run("error when neither plus nor pgs exists", func(t *testing.T) {
638 dbpool := pgsdb.NewDBMemory(logger)
639 _, err := findFeatureFlag(dbpool, cfg, userID)
640 if err == nil {
641 t.Fatal("expected error, got nil")
642 }
643 if !strings.Contains(err.Error(), "pico+") || !strings.Contains(err.Error(), "https://blog.pico.sh/ann-038-pico-invite-system") {
644 t.Errorf("expected pico+ requirement and blog link in error, got %v", err)
645 }
646 })
647}
648
649func TestPgsFlagAllowsUpload(t *testing.T) {
650 dbpool := pgsdb.NewDBMemory(slog.Default())
651 dbpool.SetupTestData()
652 // Replace default plus flag with a valid pgs flag
653 dbpool.Feature = nil
654 valid := time.Now().Add(24 * time.Hour)
655 pgsFF := db.NewFeatureFlag(
656 dbpool.Users[0].ID,
657 "pgs",
658 uint64(25*shared.MB),
659 int64(10*shared.MB),
660 int64(5*shared.KB),
661 )
662 pgsFF.ExpiresAt = &valid
663 dbpool.Features = []*db.FeatureFlag{pgsFF}
664
665 addr, teardown := setupPgsTestServer(t, dbpool)
666 defer teardown()
667
668 user := GenerateUser()
669 dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
670 ID: "pgs-user-pubkey",
671 UserID: dbpool.Users[0].ID,
672 Key: shared.KeyForKeyText(user.signer.PublicKey()),
673 })
674
675 conn, err := user.NewClientAddr(addr)
676 if err != nil {
677 t.Fatalf("ssh dial failed: %v", err)
678 }
679 defer func() { _ = conn.Close() }()
680
681 client, err := sftp.NewClient(conn)
682 if err != nil {
683 t.Fatalf("sftp client failed: %v", err)
684 }
685 defer func() { _ = client.Close() }()
686
687 // Upload should succeed for a pgs user.
688 f, err := client.Create("myproject/index.html")
689 if err != nil {
690 t.Fatalf("pgs user upload was blocked unexpectedly: %v", err)
691 }
692 if _, err := f.Write([]byte("<html>hello from pgs</html>")); err != nil {
693 t.Fatalf("write failed: %v", err)
694 }
695 _ = f.Close()
696
697 // Confirm the file is accessible via SFTP stat.
698 fi, err := client.Lstat("myproject/index.html")
699 if err != nil {
700 t.Fatalf("file not found after upload: %v", err)
701 }
702 if fi.Size() == 0 {
703 t.Errorf("uploaded file has zero size")
704 }
705}
706
707// SFTP directories behave like a filesystem's on storage that has real
708// directories, with no marker files left behind.
709func TestSftpDirectories(t *testing.T) {
710 logger := slog.New(slog.DiscardHandler)
711 dbpool := pgsdb.NewDBMemory(logger)
712 dbpool.SetupTestData()
713 storageDir := t.TempDir()
714 st, err := storage.NewStorageFS(logger, storageDir)
715 if err != nil {
716 t.Fatal(err)
717 }
718 _ = os.Setenv("PGS_SSH_PORT", "0")
719 cfg := NewConfig(logger, dbpool, st, discardPubsub{})
720 done := make(chan error)
721 defer close(done)
722 readyCh := make(chan *pssh.SSHServer)
723 go StartSshServerForTesting(cfg, done, readyCh)
724 server := <-readyCh
725 if server == nil {
726 t.Fatal("failed to create ssh server")
727 }
728 var addr string
729 for range 100 {
730 server.Mu.Lock()
731 listener := server.Listener
732 server.Mu.Unlock()
733 if listener != nil {
734 addr = listener.Addr().String()
735 break
736 }
737 time.Sleep(10 * time.Millisecond)
738 }
739
740 user := GenerateUser()
741 dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
742 ID: "test-pubkey-dirs",
743 UserID: dbpool.Users[0].ID,
744 Key: shared.KeyForKeyText(user.signer.PublicKey()),
745 })
746 conn, err := user.NewClientAddr(addr)
747 if err != nil {
748 t.Fatal(err)
749 }
750 defer func() { _ = conn.Close() }()
751 client, err := sftp.NewClient(conn)
752 if err != nil {
753 t.Fatal(err)
754 }
755 defer func() { _ = client.Close() }()
756
757 if err := client.MkdirAll("/site/empty"); err != nil {
758 t.Fatal(err)
759 }
760 f, err := client.Create("/site/css/site.css")
761 if err != nil {
762 t.Fatal(err)
763 }
764 if _, err := f.Write([]byte("body{}")); err != nil {
765 t.Fatal(err)
766 }
767 _ = f.Close()
768
769 if err := client.RemoveDirectory("/site/css"); err == nil {
770 t.Fatal("removing a non-empty directory succeeded")
771 }
772 if err := client.Remove("/site/css/site.css"); err != nil {
773 t.Fatal(err)
774 }
775 for _, dir := range []string{"/site/empty", "/site/css"} {
776 fi, err := client.Stat(dir)
777 if err != nil || !fi.IsDir() {
778 t.Fatalf("%s should be an empty directory: %v", dir, err)
779 }
780 if err := client.RemoveDirectory(dir); err != nil {
781 t.Fatalf("rmdir %s: %v", dir, err)
782 }
783 if _, err := client.Stat(dir); err == nil {
784 t.Fatalf("%s still exists after rmdir", dir)
785 }
786 }
787
788 err = filepath.WalkDir(storageDir, func(p string, d fs.DirEntry, err error) error {
789 if err == nil && !d.IsDir() {
790 t.Errorf("unexpected file left in storage: %s", p)
791 }
792 return err
793 })
794 if err != nil {
795 t.Fatal(err)
796 }
797}