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}