main pico / pkg / apps / pgs / scp_sftp_test.go
Antonio Mika  ·  2026-10-11
  1package pgs
  2
  3import (
  4	"errors"
  5	"io"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"slices"
 10	"strings"
 11	"testing"
 12	"time"
 13
 14	"github.com/pkg/sftp"
 15)
 16
 17// uploadModes upload the contents of a local directory to /site. OpenSSH's
 18// scp uses SFTP unless -O asks for the legacy protocol.
 19var uploadModes = map[string]func(e *rsyncEnv, dir string) *exec.Cmd{
 20	"scp-legacy": func(e *rsyncEnv, dir string) *exec.Cmd {
 21		return exec.Command("scp", append(append([]string{"-O", "-r"}, e.sshArgs...), dir+"/.", "localhost:/site")...)
 22	},
 23	"scp-sftp": func(e *rsyncEnv, dir string) *exec.Cmd {
 24		return exec.Command("scp", append(append([]string{"-s", "-r"}, e.sshArgs...), dir+"/.", "localhost:/site")...)
 25	},
 26	"sftp": func(e *rsyncEnv, dir string) *exec.Cmd {
 27		return e.sftp("-mkdir /site", "put -r "+dir+"/. /site")
 28	},
 29}
 30
 31func (e *rsyncEnv) sftp(cmds ...string) *exec.Cmd {
 32	cmd := exec.Command("sftp", append(append([]string{"-b", "-"}, e.sshArgs...), "localhost")...)
 33	cmd.Stdin = strings.NewReader(strings.Join(cmds, "\n") + "\n")
 34	return cmd
 35}
 36
 37func runCmd(t *testing.T, cmd *exec.Cmd) (string, int) {
 38	t.Helper()
 39	out, err := cmd.CombinedOutput()
 40	var exit *exec.ExitError
 41	switch {
 42	case errors.As(err, &exit):
 43		return string(out), exit.ExitCode()
 44	case err != nil:
 45		t.Fatalf("%s: %v", cmd.Path, err)
 46	}
 47	return string(out), 0
 48}
 49
 50func (e *rsyncEnv) assertStored(files map[string]string) {
 51	e.t.Helper()
 52	for name, want := range files {
 53		if got, _, ok := e.stored("/site/" + name); !ok || string(got) != want {
 54			e.t.Errorf("%s stored=%v contents=%q, want %q", name, ok, got, want)
 55		}
 56	}
 57}
 58
 59// waitStored waits for files to be stored. sshfs closes a file after the
 60// program writing it has exited, and the file is stored on close.
 61func (e *rsyncEnv) waitStored(files map[string]string) {
 62	e.t.Helper()
 63	for range 50 {
 64		done := true
 65		for name, want := range files {
 66			if got, _, ok := e.stored("/site/" + name); !ok || string(got) != want {
 67				done = false
 68			}
 69		}
 70		if done {
 71			return
 72		}
 73		time.Sleep(20 * time.Millisecond)
 74	}
 75	e.assertStored(files)
 76}
 77
 78func TestScpSftpUpload(t *testing.T) {
 79	newStorage := rsyncStorageBackends()["memory"]
 80	mtime := time.Unix(1_600_000_000, 0)
 81	for mode, upload := range uploadModes {
 82		t.Run(mode, func(t *testing.T) {
 83			t.Run("tree and overwrite", func(t *testing.T) {
 84				env := newRsyncEnv(t, newStorage(t))
 85				src := tree{t, t.TempDir()}
 86				files := map[string]string{"index.html": "index", "css/site.css": "css", "img/a/b.png": "png"}
 87				for name, contents := range files {
 88					src.write(name, []byte(contents), mtime)
 89				}
 90				if out, code := runCmd(t, upload(env, src.dir)); code != 0 {
 91					t.Fatalf("exit %d\n%s", code, out)
 92				}
 93				env.assertStored(files)
 94
 95				files["index.html"] = "changed"
 96				src.write("index.html", []byte("changed"), mtime)
 97				if out, code := runCmd(t, upload(env, src.dir)); code != 0 {
 98					t.Fatalf("exit %d\n%s", code, out)
 99				}
100				env.assertStored(files)
101			})
102
103			t.Run("rejected file fails without stopping the rest", func(t *testing.T) {
104				env := newRsyncEnv(t, newStorage(t))
105				env.feature.Data.FileMax = 100
106				src := tree{t, t.TempDir()}
107				ok := map[string]string{"a1.txt": "ok", "c3.txt": "ok", "sub/d4.txt": "ok"}
108				for name, contents := range ok {
109					src.write(name, []byte(contents), mtime)
110				}
111				big := strings.Repeat("x", 200)
112				src.write("b2.bin", []byte(big), mtime)
113				out, code := runCmd(t, upload(env, src.dir))
114				if code == 0 {
115					t.Errorf("rejected upload exited 0\n%s", out)
116				}
117				if !strings.Contains(out, "stream size exceeded") {
118					t.Errorf("no rejection message\n%s", out)
119				}
120				env.assertStored(ok)
121				if got, _, _ := env.stored("/site/b2.bin"); string(got) == big {
122					t.Error("oversized file was stored")
123				}
124			})
125
126			t.Run("ignored dotfile", func(t *testing.T) {
127				env := newRsyncEnv(t, newStorage(t))
128				src := tree{t, t.TempDir()}
129				ok := map[string]string{"a1.txt": "ok", "z9.txt": "ok"}
130				for name, contents := range ok {
131					src.write(name, []byte(contents), mtime)
132				}
133				src.write(".secret", []byte("dot"), mtime)
134				out, code := runCmd(t, upload(env, src.dir))
135				if !strings.Contains(out, "file ignored: (/site/.secret)") {
136					t.Errorf("no ignore message\n%s", out)
137				}
138				if code != 0 {
139					t.Errorf("ignored file failed the upload: exit %d\n%s", code, out)
140				}
141				env.assertStored(ok)
142				if _, _, stored := env.stored("/site/.secret"); stored {
143					t.Error("ignored file was stored")
144				}
145			})
146		})
147	}
148}
149
150func TestScpSftpDownloadAndCommands(t *testing.T) {
151	env := newRsyncEnv(t, rsyncStorageBackends()["memory"](t))
152	src := tree{t, t.TempDir()}
153	files := map[string]string{"index.html": "index", "css/site.css": "css", "old.txt": "old"}
154	for name, contents := range files {
155		src.write(name, []byte(contents), time.Unix(1_600_000_000, 0))
156	}
157	if out, code := runCmd(t, uploadModes["sftp"](env, src.dir)); code != 0 {
158		t.Fatalf("exit %d\n%s", code, out)
159	}
160
161	t.Run("sftp get", func(t *testing.T) {
162		dst := t.TempDir()
163		if out, code := runCmd(t, env.sftp("get -r /site "+dst)); code != 0 {
164			t.Fatalf("exit %d\n%s", code, out)
165		}
166		for name, want := range files {
167			if got, err := os.ReadFile(filepath.Join(dst, "site", name)); err != nil || string(got) != want {
168				t.Errorf("%s: %q %v", name, got, err)
169			}
170		}
171	})
172
173	t.Run("scp download over sftp", func(t *testing.T) {
174		dst := t.TempDir()
175		cmd := exec.Command("scp", append(append([]string{"-s", "-r"}, env.sshArgs...), "localhost:/site/css", dst)...)
176		if out, code := runCmd(t, cmd); code != 0 {
177			t.Fatalf("exit %d\n%s", code, out)
178		}
179		if got, err := os.ReadFile(filepath.Join(dst, "css/site.css")); err != nil || string(got) != "css" {
180			t.Errorf("site.css: %q %v", got, err)
181		}
182	})
183
184	t.Run("legacy scp download is refused", func(t *testing.T) {
185		cmd := exec.Command("scp", append(append([]string{"-O"}, env.sshArgs...), "localhost:/site/index.html", t.TempDir())...)
186		out, code := runCmd(t, cmd)
187		if code == 0 || !strings.Contains(out, "unsupported, use rsync or sftp") {
188			t.Fatalf("exit %d\n%s", code, out)
189		}
190	})
191
192	t.Run("sftp ls rm", func(t *testing.T) {
193		out, code := runCmd(t, env.sftp("ls /site", "rm /site/old.txt"))
194		if code != 0 {
195			t.Fatalf("exit %d\n%s", code, out)
196		}
197		for _, name := range []string{"index.html", "old.txt", "css"} {
198			if !strings.Contains(out, name) {
199				t.Errorf("ls is missing %s\n%s", name, out)
200			}
201		}
202		if _, _, ok := env.stored("/site/old.txt"); ok {
203			t.Error("rm did not delete the file")
204		}
205		env.assertStored(map[string]string{"index.html": "index", "css/site.css": "css"})
206	})
207}
208
209func TestSftpIgnoredFileKeepsBatchGoing(t *testing.T) {
210	env := newRsyncEnv(t, rsyncStorageBackends()["memory"](t))
211	src := tree{t, t.TempDir()}
212	src.write(".secret", []byte("dot"), time.Unix(1_600_000_000, 0))
213	src.write("after.txt", []byte("after"), time.Unix(1_600_000_000, 0))
214	out, code := runCmd(t, env.sftp(
215		"-mkdir /site",
216		"put "+filepath.Join(src.dir, ".secret")+" /site/.secret",
217		"put "+filepath.Join(src.dir, "after.txt")+" /site/after.txt",
218	))
219	if code != 0 {
220		t.Fatalf("exit %d\n%s", code, out)
221	}
222	env.assertStored(map[string]string{"after.txt": "after"})
223	if _, _, ok := env.stored("/site/.secret"); ok {
224		t.Error("ignored file was stored")
225	}
226}
227
228func TestSftpRejectedUploadKeepsStoredFile(t *testing.T) {
229	for _, mode := range []string{"scp-sftp", "sftp"} {
230		t.Run(mode, func(t *testing.T) {
231			env := newRsyncEnv(t, rsyncStorageBackends()["memory"](t))
232			src := tree{t, t.TempDir()}
233			src.write("page.html", []byte("original"), time.Unix(1_600_000_000, 0))
234			if out, code := runCmd(t, uploadModes[mode](env, src.dir)); code != 0 {
235				t.Fatalf("exit %d\n%s", code, out)
236			}
237			env.feature.Data.FileMax = 10
238			src.write("page.html", []byte(strings.Repeat("x", 100)), time.Unix(1_600_000_000, 0))
239			src.write("new.html", []byte(strings.Repeat("y", 100)), time.Unix(1_600_000_000, 0))
240			if out, code := runCmd(t, uploadModes[mode](env, src.dir)); code == 0 {
241				t.Fatalf("oversized upload exited 0\n%s", out)
242			}
243			env.assertStored(map[string]string{"page.html": "original"})
244			if _, _, ok := env.stored("/site/new.html"); ok {
245				t.Error("rejected new file left an object in storage")
246			}
247		})
248	}
249}
250
251// TestSftpOpenFiles drives the calls sshfs makes for touch and editors.
252func TestSftpOpenFiles(t *testing.T) {
253	env := newRsyncEnv(t, rsyncStorageBackends()["memory"](t))
254	conn, err := env.user.NewClientAddr(env.addr)
255	if err != nil {
256		t.Fatal(err)
257	}
258	defer func() { _ = conn.Close() }()
259	client, err := sftp.NewClient(conn)
260	if err != nil {
261		t.Fatal(err)
262	}
263	defer func() { _ = client.Close() }()
264	_ = client.Mkdir("/site")
265
266	put := func(name, contents string) {
267		t.Helper()
268		f, err := client.Create(name)
269		if err != nil {
270			t.Fatal(err)
271		}
272		if _, err := f.Write([]byte(contents)); err != nil {
273			t.Fatal(err)
274		}
275		if err := f.Close(); err != nil {
276			t.Fatal(err)
277		}
278	}
279
280	t.Run("touch keeps an existing file", func(t *testing.T) {
281		put("/site/a.txt", "hello")
282		f, err := client.OpenFile("/site/a.txt", os.O_WRONLY|os.O_CREATE)
283		if err != nil {
284			t.Fatal(err)
285		}
286		if err := f.Close(); err != nil {
287			t.Fatal(err)
288		}
289		env.assertStored(map[string]string{"a.txt": "hello"})
290	})
291
292	t.Run("touch creates an empty file", func(t *testing.T) {
293		f, err := client.OpenFile("/site/empty.txt", os.O_WRONLY|os.O_CREATE)
294		if err != nil {
295			t.Fatal(err)
296		}
297		if _, err := client.Stat("/site/empty.txt"); err != nil {
298			t.Errorf("open file not visible to stat: %v", err)
299		}
300		if _, _, ok := env.stored("/site/empty.txt"); ok {
301			t.Error("file stored before close")
302		}
303		if err := f.Close(); err != nil {
304			t.Fatal(err)
305		}
306		env.assertStored(map[string]string{"empty.txt": ""})
307	})
308
309	t.Run("open file is listed and readable", func(t *testing.T) {
310		f, err := client.OpenFile("/site/draft.txt", os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
311		if err != nil {
312			t.Fatal(err)
313		}
314		if _, err := f.Write([]byte("draft")); err != nil {
315			t.Fatal(err)
316		}
317		infos, err := client.ReadDir("/site")
318		if err != nil {
319			t.Fatal(err)
320		}
321		listed := false
322		for _, info := range infos {
323			if info.Name() == "draft.txt" && info.Size() == 5 {
324				listed = true
325			}
326		}
327		if !listed {
328			t.Error("open file missing from the listing")
329		}
330		r, err := client.Open("/site/draft.txt")
331		if err != nil {
332			t.Fatal(err)
333		}
334		got, _ := io.ReadAll(r)
335		_ = r.Close()
336		if string(got) != "draft" {
337			t.Errorf("read %q from the open file", got)
338		}
339		if err := f.Close(); err != nil {
340			t.Fatal(err)
341		}
342		env.assertStored(map[string]string{"draft.txt": "draft"})
343	})
344
345	t.Run("write at an offset keeps the rest", func(t *testing.T) {
346		put("/site/b.txt", "hello world")
347		f, err := client.OpenFile("/site/b.txt", os.O_WRONLY)
348		if err != nil {
349			t.Fatal(err)
350		}
351		if _, err := f.WriteAt([]byte("WORLD"), 6); err != nil {
352			t.Fatal(err)
353		}
354		if err := f.Close(); err != nil {
355			t.Fatal(err)
356		}
357		env.assertStored(map[string]string{"b.txt": "hello WORLD"})
358	})
359
360	t.Run("truncate keeps nothing", func(t *testing.T) {
361		put("/site/c.txt", "old")
362		f, err := client.OpenFile("/site/c.txt", os.O_WRONLY|os.O_TRUNC)
363		if err != nil {
364			t.Fatal(err)
365		}
366		if err := f.Close(); err != nil {
367			t.Fatal(err)
368		}
369		env.assertStored(map[string]string{"c.txt": ""})
370	})
371
372	t.Run("rename", func(t *testing.T) {
373		put("/site/r1.txt", "one")
374		put("/site/r2.txt", "two")
375		if err := client.Rename("/site/r1.txt", "/site/r2.txt"); err == nil {
376			t.Error("rename replaced an existing file")
377		}
378		if err := client.PosixRename("/site/r1.txt", "/site/r2.txt"); err != nil {
379			t.Fatal(err)
380		}
381		env.assertStored(map[string]string{"r2.txt": "one"})
382		if _, _, ok := env.stored("/site/r1.txt"); ok {
383			t.Error("source survived the rename")
384		}
385		if err := client.Rename("/site/r2.txt", "/site/.r2"); err == nil {
386			t.Error("rename to an ignored name succeeded")
387		}
388		env.assertStored(map[string]string{"r2.txt": "one"})
389
390		f, err := client.OpenFile("/site/r2.txt", os.O_WRONLY)
391		if err != nil {
392			t.Fatal(err)
393		}
394		if _, err := f.WriteAt([]byte("ONE"), 0); err != nil {
395			t.Fatal(err)
396		}
397		if err := client.Rename("/site/r2.txt", "/site/r3.txt"); err != nil {
398			t.Fatal(err)
399		}
400		if err := f.Close(); err != nil {
401			t.Fatal(err)
402		}
403		env.assertStored(map[string]string{"r3.txt": "ONE"})
404		if _, _, ok := env.stored("/site/r2.txt"); ok {
405			t.Error("open file's old path is still stored after it closed")
406		}
407	})
408
409	t.Run("truncate then write past the end leaves zeros", func(t *testing.T) {
410		put("/site/z.txt", "secret")
411		f, err := client.OpenFile("/site/z.txt", os.O_WRONLY)
412		if err != nil {
413			t.Fatal(err)
414		}
415		if err := f.Truncate(0); err != nil {
416			t.Fatal(err)
417		}
418		if _, err := f.WriteAt([]byte("x"), 5); err != nil {
419			t.Fatal(err)
420		}
421		if err := f.Close(); err != nil {
422			t.Fatal(err)
423		}
424		env.assertStored(map[string]string{"z.txt": "\x00\x00\x00\x00\x00x"})
425	})
426
427	t.Run("renaming an open file keeps the source until it is stored", func(t *testing.T) {
428		openAndWrite := func(name, contents string) *sftp.File {
429			t.Helper()
430			f, err := client.OpenFile(name, os.O_WRONLY)
431			if err != nil {
432				t.Fatal(err)
433			}
434			if _, err := f.WriteAt([]byte(contents), 0); err != nil {
435				t.Fatal(err)
436			}
437			return f
438		}
439
440		put("/site/k.txt", "keep")
441		f := openAndWrite("/site/k.txt", "KEEP")
442		if err := client.PosixRename("/site/k.txt", "/site/.ignored"); err == nil {
443			t.Error("renamed an open file to an ignored name")
444		}
445		_ = f.Close()
446		env.assertStored(map[string]string{"k.txt": "KEEP"})
447
448		put("/site/k.txt", "keep")
449		f = openAndWrite("/site/k.txt", strings.Repeat("x", 200))
450		if err := client.PosixRename("/site/k.txt", "/site/big.txt"); err != nil {
451			t.Fatal(err)
452		}
453		if _, err := client.Stat("/site/k.txt"); !errors.Is(err, os.ErrNotExist) {
454			t.Errorf("renamed source still visible: %v", err)
455		}
456		env.feature.Data.FileMax = 100
457		if err := f.Close(); err == nil {
458			t.Error("oversized close succeeded")
459		}
460		env.feature.Data.FileMax = rsyncFileMax
461		env.assertStored(map[string]string{"k.txt": "keep"})
462		if _, _, ok := env.stored("/site/big.txt"); ok {
463			t.Error("rejected destination was stored")
464		}
465
466		f = openAndWrite("/site/k.txt", "NEW!")
467		if err := client.PosixRename("/site/k.txt", "/site/moved.txt"); err != nil {
468			t.Fatal(err)
469		}
470		env.assertStored(map[string]string{"k.txt": "keep"})
471		put("/site/k.txt", "replacement")
472		if err := f.Close(); err != nil {
473			t.Fatal(err)
474		}
475		env.assertStored(map[string]string{"moved.txt": "NEW!", "k.txt": "replacement"})
476
477		put("/site/k2.txt", "k2")
478		f = openAndWrite("/site/k2.txt", "K2")
479		if err := client.PosixRename("/site/k2.txt", "/site/moved2.txt"); err != nil {
480			t.Fatal(err)
481		}
482		if err := f.Close(); err != nil {
483			t.Fatal(err)
484		}
485		env.assertStored(map[string]string{"moved2.txt": "K2"})
486		if _, _, ok := env.stored("/site/k2.txt"); ok {
487			t.Error("source kept after the renamed file was stored")
488		}
489	})
490
491	t.Run("renamed source replaced by another session survives", func(t *testing.T) {
492		conn2, err := env.user.NewClientAddr(env.addr)
493		if err != nil {
494			t.Fatal(err)
495		}
496		defer func() { _ = conn2.Close() }()
497		other, err := sftp.NewClient(conn2)
498		if err != nil {
499			t.Fatal(err)
500		}
501		defer func() { _ = other.Close() }()
502
503		put("/site/s1.txt", "orig")
504		f, err := client.OpenFile("/site/s1.txt", os.O_WRONLY)
505		if err != nil {
506			t.Fatal(err)
507		}
508		if _, err := f.WriteAt([]byte("ORIG"), 0); err != nil {
509			t.Fatal(err)
510		}
511		if err := client.PosixRename("/site/s1.txt", "/site/s2.txt"); err != nil {
512			t.Fatal(err)
513		}
514		g, err := other.Create("/site/s1.txt")
515		if err != nil {
516			t.Fatal(err)
517		}
518		if _, err := g.Write([]byte("from another session")); err != nil {
519			t.Fatal(err)
520		}
521		if err := g.Close(); err != nil {
522			t.Fatal(err)
523		}
524		if err := f.Close(); err != nil {
525			t.Fatal(err)
526		}
527		env.assertStored(map[string]string{"s1.txt": "from another session", "s2.txt": "ORIG"})
528	})
529
530	t.Run("removing a file renamed over a stored one removes both", func(t *testing.T) {
531		put("/site/old.txt", "old")
532		put("/site/src.txt", "src")
533		f, err := client.OpenFile("/site/src.txt", os.O_WRONLY)
534		if err != nil {
535			t.Fatal(err)
536		}
537		if _, err := f.WriteAt([]byte("SRC"), 0); err != nil {
538			t.Fatal(err)
539		}
540		if err := client.PosixRename("/site/src.txt", "/site/old.txt"); err != nil {
541			t.Fatal(err)
542		}
543		if err := client.Remove("/site/old.txt"); err != nil {
544			t.Fatal(err)
545		}
546		if err := f.Close(); err != nil {
547			t.Fatal(err)
548		}
549		for _, name := range []string{"old.txt", "src.txt"} {
550			if _, _, ok := env.stored("/site/" + name); ok {
551				t.Errorf("%s is still stored", name)
552			}
553		}
554	})
555
556	t.Run("removed while open is not stored", func(t *testing.T) {
557		f, err := client.OpenFile("/site/swap.txt", os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
558		if err != nil {
559			t.Fatal(err)
560		}
561		if _, err := f.Write([]byte("swap")); err != nil {
562			t.Fatal(err)
563		}
564		if err := client.Remove("/site/swap.txt"); err != nil {
565			t.Fatal(err)
566		}
567		if err := f.Close(); err != nil {
568			t.Fatal(err)
569		}
570		if _, _, ok := env.stored("/site/swap.txt"); ok {
571			t.Error("file removed while open was stored")
572		}
573	})
574}
575
576// TestSshfs mounts pgs with sshfs and edits files the way shells and
577// editors do.
578func TestSshfs(t *testing.T) {
579	if _, err := exec.LookPath("sshfs"); err != nil {
580		t.Skip("sshfs not installed")
581	}
582	for name, newStorage := range rsyncStorageBackends() {
583		t.Run(name, func(t *testing.T) { testSshfs(t, newRsyncEnv(t, newStorage(t))) })
584	}
585}
586
587func testSshfs(t *testing.T, env *rsyncEnv) {
588	mnt := t.TempDir()
589	opts := []string{"-p", env.sshArgs[1], "-o", "IdentityFile=" + env.sshArgs[3], "-o", "IdentitiesOnly=yes",
590		"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"}
591	if out, err := exec.Command("sshfs", append(opts, "localhost:/", mnt)...).CombinedOutput(); err != nil {
592		t.Skipf("sshfs mount failed: %v\n%s", err, out)
593	}
594	t.Cleanup(func() { _ = exec.Command("fusermount3", "-u", "-z", mnt).Run() })
595
596	home := t.TempDir()
597	steps := []struct {
598		cmd    string
599		fails  bool
600		stored map[string]string
601	}{
602		{cmd: "mkdir site && echo hello > site/a.txt", stored: map[string]string{"a.txt": "hello\n"}},
603		{cmd: "touch site/a.txt", stored: map[string]string{"a.txt": "hello\n"}},
604		{cmd: "touch site/new.txt", stored: map[string]string{"new.txt": ""}},
605		{cmd: "vim -n -es -c 'normal! ihi' -c wq site/vim.txt", stored: map[string]string{"vim.txt": "hi\n"}},
606		{cmd: "vim -es -c '%s/hello/bye/' -c wq site/a.txt", stored: map[string]string{"a.txt": "bye\n"}},
607		{cmd: "echo more >> site/a.txt", stored: map[string]string{"a.txt": "bye\nmore\n"}},
608		{cmd: "printf abc > site/t.txt && truncate -s 1 site/t.txt", stored: map[string]string{"t.txt": "a"}},
609		{cmd: "rm site/new.txt", stored: map[string]string{"a.txt": "bye\nmore\n"}},
610		{cmd: "echo secret > site/.dot", fails: true},
611		{cmd: "mv site/t.txt site/moved.txt", stored: map[string]string{"moved.txt": "a"}},
612		{cmd: "mv site/moved.txt site/a.txt", stored: map[string]string{"a.txt": "a"}},
613		{cmd: "mv site/a.txt site/.dot", fails: true, stored: map[string]string{"a.txt": "a"}},
614		{cmd: "mkdir -p site/dir/sub site/empty && echo y > site/dir/sub/y.txt && mv site/dir site/dir2 && mv site/a.txt site/dir2/", stored: map[string]string{"dir2/sub/y.txt": "y\n", "dir2/a.txt": "a"}},
615		{cmd: "mv site/empty site/empty2 && test -d site/empty2 && ! test -e site/empty && ! test -e site/dir"},
616		{cmd: "echo q > site/q.txt && mv site/q.txt site/r.txt && cat site/r.txt", stored: map[string]string{"r.txt": "q\n"}},
617	}
618	for _, step := range steps {
619		cmd := exec.Command("bash", "-c", step.cmd)
620		cmd.Dir = mnt
621		cmd.Env = append(os.Environ(), "HOME="+home)
622		out, err := cmd.CombinedOutput()
623		if (err != nil) != step.fails {
624			t.Fatalf("%s: err=%v\n%s", step.cmd, err, out)
625		}
626		env.waitStored(step.stored)
627	}
628	for _, name := range []string{"new.txt", ".dot", "t.txt", "moved.txt", "a.txt", "dir/sub/y.txt", "q.txt"} {
629		if _, _, ok := env.stored("/site/" + name); ok {
630			t.Errorf("%s is stored", name)
631		}
632	}
633}
634
635func TestSingleFileIntoProject(t *testing.T) {
636	newStorage := rsyncStorageBackends()["memory"]
637	clients := map[string]func(e *rsyncEnv, src, dest string) *exec.Cmd{
638		"scp-legacy": func(e *rsyncEnv, src, dest string) *exec.Cmd {
639			return exec.Command("scp", append(append([]string{"-O"}, e.sshArgs...), src, "localhost:"+dest)...)
640		},
641		"scp-sftp": func(e *rsyncEnv, src, dest string) *exec.Cmd {
642			return exec.Command("scp", append(append([]string{"-s"}, e.sshArgs...), src, "localhost:"+dest)...)
643		},
644		"sftp": func(e *rsyncEnv, src, dest string) *exec.Cmd {
645			return e.sftp("put " + src + " " + dest)
646		},
647		"rsync": func(e *rsyncEnv, src, dest string) *exec.Cmd {
648			return exec.Command("rsync", "-e", e.rsh, src, "localhost:"+dest)
649		},
650	}
651	for name, client := range clients {
652		for _, dest := range []string{"/site", "/site/"} {
653			t.Run(name+" "+dest, func(t *testing.T) {
654				env := newRsyncEnv(t, newStorage(t))
655				src := tree{t, t.TempDir()}
656				src.write("post.md", []byte("hello"), time.Now())
657				out, code := runCmd(t, client(env, filepath.Join(src.dir, "post.md"), dest))
658				if code != 0 {
659					t.Fatalf("exit %d\n%s", code, out)
660				}
661				env.assertStored(map[string]string{"post.md": "hello"})
662				lines := strings.Split(out, "\n")
663				if !slices.ContainsFunc(lines, func(l string) bool { return strings.TrimSpace(l) == "https://testusr-site.pgs.sh/post.md" }) {
664					t.Errorf("wrong url\n%s", out)
665				}
666			})
667		}
668		t.Run(name+" /", func(t *testing.T) {
669			env := newRsyncEnv(t, newStorage(t))
670			src := tree{t, t.TempDir()}
671			src.write("post.md", []byte("hello"), time.Now())
672			out, code := runCmd(t, client(env, filepath.Join(src.dir, "post.md"), "/"))
673			if code == 0 {
674				t.Errorf("file at the root exited 0\n%s", out)
675			}
676			if !strings.Contains(out, "you must copy files to a non-root folder") {
677				t.Errorf("no root error\n%s", out)
678			}
679		})
680	}
681}