main pico / pkg / postfs / sftp_test.go
Antonio Mika  ·  2026-10-11
  1package postfs_test
  2
  3import (
  4	"context"
  5	"crypto/ed25519"
  6	"crypto/rand"
  7	"encoding/pem"
  8	"log/slog"
  9	"net"
 10	"os"
 11	"os/exec"
 12	"path/filepath"
 13	"slices"
 14	"strings"
 15	"testing"
 16	"time"
 17
 18	"github.com/picosh/pico/pkg/apps/pastes"
 19	"github.com/picosh/pico/pkg/apps/prose"
 20	"github.com/picosh/pico/pkg/db"
 21	"github.com/picosh/pico/pkg/db/stub"
 22	"github.com/picosh/pico/pkg/pssh"
 23	"github.com/picosh/pico/pkg/send/list"
 24	"github.com/picosh/pico/pkg/send/protocols/sftp"
 25	"github.com/picosh/pico/pkg/send/utils"
 26	"github.com/picosh/pico/pkg/shared"
 27	"github.com/picosh/pico/pkg/storage"
 28	"golang.org/x/crypto/ssh"
 29)
 30
 31type postsEnv struct {
 32	t     *testing.T
 33	db    *stub.PostsDB
 34	space string
 35	port  string
 36	key   string
 37}
 38
 39func newPostsEnv(t *testing.T, app string) *postsEnv {
 40	t.Helper()
 41	logger := slog.New(slog.DiscardHandler)
 42	_, userKey, err := ed25519.GenerateKey(rand.Reader)
 43	if err != nil {
 44		t.Fatal(err)
 45	}
 46	signer, err := ssh.NewSignerFromKey(userKey)
 47	if err != nil {
 48		t.Fatal(err)
 49	}
 50	posts := stub.NewPostsDB(logger, &db.User{ID: "user-1", Name: "tester"})
 51	posts.Pubkey = shared.KeyForKeyText(signer.PublicKey())
 52
 53	var space string
 54	var handler interface {
 55		utils.CopyFromClientHandler
 56		pssh.GetLoggerInterface
 57	}
 58	switch app {
 59	case "prose":
 60		cfg := prose.NewConfig("prose-test")
 61		cfg.Logger = logger
 62		st, err := storage.NewStorageMemory(map[string]map[string]string{})
 63		if err != nil {
 64			t.Fatal(err)
 65		}
 66		handler = prose.NewFileHandler(cfg, posts, st)
 67		space = cfg.Space
 68	case "pastes":
 69		cfg := pastes.NewConfig("pastes-test")
 70		cfg.Logger = logger
 71		handler = pastes.NewUploadHandler(cfg, posts)
 72		space = cfg.Space
 73	}
 74
 75	ctx, cancel := context.WithCancel(context.Background())
 76	t.Cleanup(cancel)
 77	auth := shared.NewSshAuthHandler(posts, logger, app)
 78	server, err := pssh.NewSSHServerWithConfig(ctx, logger, app, "127.0.0.1", "0", "", filepath.Join(t.TempDir(), "host_key"),
 79		auth.PubkeyAuthHandler,
 80		[]pssh.SSHServerMiddleware{list.Middleware(handler), pssh.LogMiddleware(handler, posts)},
 81		[]pssh.SSHServerMiddleware{sftp.Middleware(handler), pssh.LogMiddleware(handler, posts)},
 82		nil,
 83	)
 84	if err != nil {
 85		t.Fatal(err)
 86	}
 87	go func() { _ = server.ListenAndServe() }()
 88	var addr string
 89	for range 100 {
 90		server.Mu.Lock()
 91		if server.Listener != nil {
 92			addr = server.Listener.Addr().String()
 93		}
 94		server.Mu.Unlock()
 95		if addr != "" {
 96			break
 97		}
 98		time.Sleep(10 * time.Millisecond)
 99	}
100	if addr == "" {
101		t.Fatal("server listener not ready")
102	}
103	_, port, _ := net.SplitHostPort(addr)
104
105	b, err := ssh.MarshalPrivateKey(userKey, "")
106	if err != nil {
107		t.Fatal(err)
108	}
109	key := filepath.Join(t.TempDir(), "id_ed25519")
110	if err := os.WriteFile(key, pem.EncodeToMemory(b), 0o600); err != nil {
111		t.Fatal(err)
112	}
113	return &postsEnv{t: t, db: posts, space: space, port: port, key: key}
114}
115
116func (e *postsEnv) sshOpts() []string {
117	return []string{"-o", "IdentityFile=" + e.key, "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=no",
118		"-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"}
119}
120
121func (e *postsEnv) run(name string, args ...string) string {
122	e.t.Helper()
123	out, err := exec.Command(name, args...).CombinedOutput()
124	if err != nil {
125		e.t.Fatalf("%s %v: %v\n%s", name, args, err, out)
126	}
127	return string(out)
128}
129
130// seed stores a hidden post, made hidden the way each service does it.
131func (e *postsEnv) seed(filename string) *db.Post {
132	e.t.Helper()
133	published := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
134	post := &db.Post{
135		UserID: "user-1", Space: e.space, Filename: filename, Slug: strings.TrimSuffix(filename, filepath.Ext(filename)),
136		Text: seedText(e.space), FileSize: len(seedText(e.space)), Hidden: true, PublishAt: &published, UpdatedAt: &published,
137	}
138	if e.space == "pastes" {
139		expires := time.Now().Add(time.Hour).Truncate(time.Second)
140		post.ExpiresAt = &expires
141	}
142	p, err := e.db.InsertPost(post)
143	if err != nil {
144		e.t.Fatal(err)
145	}
146	return p
147}
148
149func seedText(space string) string {
150	if space == "prose" {
151		return "---\ndraft: true\n---\nseeded\n"
152	}
153	return "seeded\n"
154}
155
156// assertKept waits for the post at filename to have text and checks it is
157// the seeded one with its settings. sshfs sends a close after the command
158// that made it returns.
159func (e *postsEnv) assertKept(filename, text string, seeded *db.Post) {
160	e.t.Helper()
161	e.assertMoved(filename, text, seeded, seeded.Hidden)
162}
163
164// assertMoved is assertKept for a post whose new name changes whether it
165// is hidden.
166func (e *postsEnv) assertMoved(filename, text string, seeded *db.Post, hidden bool) {
167	e.t.Helper()
168	var got *db.Post
169	for range 100 {
170		var err error
171		if got, err = e.db.FindPostWithFilename(filename, "user-1", e.space); err == nil && got.Text == text {
172			break
173		}
174		time.Sleep(20 * time.Millisecond)
175	}
176	if got == nil || got.Text != text {
177		e.t.Fatalf("%s = %+v, want text %q", filename, got, text)
178	}
179	if got.ID != seeded.ID || !got.PublishAt.Equal(*seeded.PublishAt) || got.Hidden != hidden ||
180		(got.ExpiresAt == nil) != (seeded.ExpiresAt == nil) || (got.ExpiresAt != nil && !got.ExpiresAt.Equal(*seeded.ExpiresAt)) {
181		e.t.Errorf("%s = id %s published %v hidden %v expires %v, want %s %v %v %v", filename,
182			got.ID, got.PublishAt, got.Hidden, got.ExpiresAt, seeded.ID, seeded.PublishAt, hidden, seeded.ExpiresAt)
183	}
184}
185
186func assertListed(t *testing.T, out string, want ...string) {
187	t.Helper()
188	var got []string
189	for _, line := range strings.Split(out, "\n") {
190		f := filepath.Base(strings.TrimSpace(line))
191		if !strings.HasPrefix(line, "sftp>") && f != "." && f != ".." && f != "/" {
192			got = append(got, f)
193		}
194	}
195	slices.Sort(got)
196	slices.Sort(want)
197	if !slices.Equal(got, want) {
198		t.Errorf("listed %q, want %q", got, want)
199	}
200}
201
202func TestPostsSftp(t *testing.T) {
203	if _, err := exec.LookPath("sftp"); err != nil {
204		t.Skip("sftp not installed")
205	}
206	for app, ext := range map[string]string{"prose": ".md", "pastes": ".txt"} {
207		t.Run(app, func(t *testing.T) {
208			e := newPostsEnv(t, app)
209			a := e.seed("a" + ext)
210			e.seed("c" + ext)
211
212			sftpCmd := func(cmds string) string {
213				cmd := exec.Command("sftp", append(append([]string{"-b", "-", "-P", e.port}, e.sshOpts()...), "localhost")...)
214				cmd.Stdin = strings.NewReader(cmds)
215				out, err := cmd.CombinedOutput()
216				if err != nil {
217					t.Fatalf("sftp: %v\n%s", err, out)
218				}
219				return string(out)
220			}
221			assertListed(t, sftpCmd("ls -1\n"), "a"+ext, "c"+ext)
222			assertListed(t, e.run("ssh", append(append([]string{"-p", e.port}, e.sshOpts()...), "localhost", "command", "ls")...), "a"+ext, "c"+ext)
223
224			sftpCmd("rename a" + ext + " b" + ext + "\n")
225			e.assertKept("b"+ext, a.Text, a)
226			sftpCmd("rename b" + ext + " c" + ext + "\n")
227			e.assertKept("c"+ext, a.Text, a)
228			assertListed(t, sftpCmd("ls -1\n"), "c"+ext)
229		})
230	}
231}
232
233func TestPostsSshfs(t *testing.T) {
234	if _, err := exec.LookPath("sshfs"); err != nil {
235		t.Skip("sshfs not installed")
236	}
237	tests := map[string][]struct {
238		cmd  string
239		name string
240		text string
241		// public is set when the new name drops the draft setting, which
242		// .lxt files don't read from front matter.
243		public bool
244	}{
245		"prose": {
246			{cmd: "echo more >> a.md", name: "a.md", text: seedText("prose") + "more\n"},
247			{cmd: "mv a.md b.md", name: "b.md", text: seedText("prose") + "more\n"},
248			{cmd: "mv b.md b.lxt", name: "b.lxt", text: seedText("prose") + "more\n", public: true},
249			{cmd: "mv b.lxt c.md", name: "c.md", text: seedText("prose") + "more\n"},
250		},
251		"pastes": {
252			{cmd: "echo more >> a.txt", name: "a.txt", text: "seeded\nmore\n"},
253			{cmd: "vim -u DEFAULTS -i NONE -N -es -c '%s/more/less/' -c wq a.txt", name: "a.txt", text: "seeded\nless\n"},
254			{cmd: "mv a.txt b.txt", name: "b.txt", text: "seeded\nless\n"},
255			{cmd: "mv b.txt c.txt", name: "c.txt", text: "seeded\nless\n"},
256		},
257	}
258	for app, steps := range tests {
259		t.Run(app, func(t *testing.T) {
260			e := newPostsEnv(t, app)
261			ext := filepath.Ext(steps[0].name)
262			a := e.seed("a" + ext)
263			e.seed("c" + ext)
264
265			mnt := t.TempDir()
266			if out, err := exec.Command("sshfs", append(append([]string{"-p", e.port}, e.sshOpts()...), "localhost:/", mnt)...).CombinedOutput(); err != nil {
267				t.Skipf("sshfs mount failed: %v\n%s", err, out)
268			}
269			t.Cleanup(func() { _ = exec.Command("fusermount3", "-u", "-z", mnt).Run() })
270			assertListed(t, e.run("ls", "-1a", mnt), "a"+ext, "c"+ext)
271
272			home := t.TempDir()
273			for _, step := range steps {
274				cmd := exec.Command("bash", "-c", step.cmd)
275				cmd.Dir = mnt
276				cmd.Env = append(os.Environ(), "HOME="+home)
277				if out, err := cmd.CombinedOutput(); err != nil {
278					t.Fatalf("%s: %v\n%s", step.cmd, err, out)
279				}
280				e.assertMoved(step.name, step.text, a, !step.public)
281			}
282			assertListed(t, e.run("ls", "-1a", mnt), "c"+ext)
283		})
284	}
285}