main pico / pkg / apps / pgs / uploader_test.go
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}