1package serve23import (4 "bytes"5 "context"6 "errors"7 "io"8 "os"9 "path/filepath"10 "strings"11 "testing"12 "time"1314 tea "charm.land/bubbletea/v2"15 gossh "golang.org/x/crypto/ssh"1617 "src.rest/cat/012/internal/confine"18 "src.rest/cat/012/internal/sheet"19 "src.rest/cat/012/internal/ui"20)2122// openExec starts a session with a terminal and an exec request for23// command, as ssh -t host command does.24func openExec(t testing.TB, c *gossh.Client, command string) (*term, error) {25 t.Helper()26 sess, err := c.NewSession()27 if err != nil {28 t.Fatal(err)29 }30 if err := sess.RequestPty("xterm-256color", 24, 90, gossh.TerminalModes{}); err != nil {31 t.Fatal(err)32 }33 tm := &term{sess: sess, done: make(chan error, 1), wrote: make(chan struct{}, 1)}34 tm.stdin, _ = sess.StdinPipe()35 sess.Stdout = tm36 sess.Stderr = tm37 if err := sess.Start(command); err != nil {38 sess.Close()39 return nil, err40 }41 go func() { tm.done <- sess.Wait() }()42 return tm, nil43}4445// saveSheet writes a one-cell sheet at path.46func saveSheet(t *testing.T, path, a1 string) {47 t.Helper()48 s := sheet.New()49 s.Set(sheet.Addr{}, a1)50 var b bytes.Buffer51 if err := s.Write(&b); err != nil {52 t.Fatal(err)53 }54 if err := os.WriteFile(path, b.Bytes(), 0o644); err != nil {55 t.Fatal(err)56 }57}5859func a1(t *testing.T, path string) string {60 t.Helper()61 f, err := os.Open(path)62 if err != nil {63 return ""64 }65 defer f.Close()66 s, err := sheet.Read(f)67 if err != nil || s.Cell(sheet.Addr{}) == nil {68 return ""69 }70 return s.Cell(sheet.Addr{}).Input71}7273// ssh -t host book.012 opens book.012 from the served directory; a name74// with no such file starts a new sheet to be saved under it.75func TestOpenFromCommandLine(t *testing.T) {76 key := newKey(t)77 srv, addr, dir := testServer(t, nil, key)78 saveSheet(t, filepath.Join(dir, "book.012"), "from disk")79 c, err := dial(addr, key, srv.HostKey())80 if err != nil {81 t.Fatal(err)82 }83 defer c.Close()84 for _, name := range []string{"book.012", "book"} {85 tm, err := openExec(t, c, name)86 if err != nil {87 t.Fatalf("%s: %v", name, err)88 }89 tm.waitFor(t, "012 - book.012", "from disk")90 io.WriteString(tm.stdin, "\x11")91 tm.waitClosed(t)92 }93 tm, err := openExec(t, c, "fresh.012")94 if err != nil {95 t.Fatal(err)96 }97 tm.waitFor(t, "012 - fresh.012", "READY")98 io.WriteString(tm.stdin, "new\r\x13")99 deadline := time.Now().Add(5 * time.Second)100 for a1(t, filepath.Join(dir, "fresh.012")) != "new" && time.Now().Before(deadline) {101 time.Sleep(10 * time.Millisecond)102 }103 if got := a1(t, filepath.Join(dir, "fresh.012")); got != "new" {104 t.Errorf("Ctrl+S saved %q", got)105 }106 io.WriteString(tm.stdin, "\x11")107 tm.waitClosed(t)108}109110// An exec request is only ever a file name inside the served directory:111// commands, names outside it or hidden, and requests without a terminal112// are refused, and nothing runs.113func TestExecConfined(t *testing.T) {114 key := newKey(t)115 srv, addr, dir := testServer(t, nil, key)116 outside := filepath.Join(filepath.Dir(dir), "outside.012")117 saveSheet(t, outside, "secret")118 os.Symlink(outside, filepath.Join(dir, "link.012"))119 os.Mkdir(filepath.Join(dir, ".012-recovery"), 0o700)120 saveSheet(t, filepath.Join(dir, ".012-recovery", "book-20260101-120000.012"), "kept")121 saveSheet(t, filepath.Join(dir, "book.012"), "ok")122 c, err := dial(addr, key, srv.HostKey())123 if err != nil {124 t.Fatal(err)125 }126 defer c.Close()127128 for _, name := range []string{"../outside.012", "../outside", outside, "link.012", "link", ".env", ".012-recovery/book-20260101-120000.012", "sub/../../outside.012"} {129 tm, err := openExec(t, c, name)130 if err != nil {131 t.Fatalf("%s: request refused before the name was checked: %v", name, err)132 }133 tm.waitClosed(t)134 out := tm.output()135 if !strings.Contains(out, "outside the served directory") || strings.Contains(out, "secret") || strings.Contains(out, "kept") {136 t.Errorf("%s: %q", name, out)137 }138 if strings.Contains(out, dir) {139 t.Errorf("%s: the message names the server's directory: %q", name, out)140 }141 }142143 // Anything but one word is refused outright, as are exec requests144 // without a terminal and names with control characters.145 for _, cmd := range []string{"cat /etc/passwd", "book.012 book.012", "sh -c id", "", "'book\x1b[2J.012'"} {146 tm, err := openExec(t, c, cmd)147 if err == nil {148 tm.waitClosed(t)149 if out := tm.output(); strings.Contains(out, "READY") {150 t.Errorf("%q started 012: %q", cmd, out)151 }152 }153 }154 sess, _ := c.NewSession()155 if out, err := sess.CombinedOutput("book.012"); err == nil || len(out) > 0 {156 t.Errorf("exec without a terminal: %q, %v", out, err)157 }158}159160// A session that idles out with unsaved changes keeps them in a161// recovery file, 0600 in a 0700 directory, and says where; one without162// changes keeps nothing.163func TestRecoveryOnIdle(t *testing.T) {164 key := newKey(t)165 srv, addr, dir := testServer(t, func(o *Options) { o.IdleTimeout = 500 * time.Millisecond }, key)166 c, err := dial(addr, key, srv.HostKey())167 if err != nil {168 t.Fatal(err)169 }170 defer c.Close()171 tm := openTerm(t, c, 80, 24)172 tm.waitFor(t, "READY")173 tm.waitClosed(t)174 if _, err := os.Stat(filepath.Join(dir, ".012-recovery")); !errors.Is(err, os.ErrNotExist) {175 t.Fatalf("an unchanged session kept something: %v", err)176 }177178 tm, err = openExec(t, c, "book.012")179 if err != nil {180 t.Fatal(err)181 }182 tm.waitFor(t, "READY")183 io.WriteString(tm.stdin, "42\r")184 tm.waitClosed(t)185 out := tm.output()186 if !strings.Contains(out, "closed after 500ms without input") ||187 !strings.Contains(out, "unsaved changes kept in .012-recovery/book-") ||188 !strings.Contains(out, "open book.012 again to restore them") {189 t.Fatalf("no recovery note in %q", out[max(0, len(out)-300):])190 }191 rdir := filepath.Join(dir, ".012-recovery")192 st, err := os.Stat(rdir)193 if err != nil || st.Mode().Perm() != 0o700 {194 t.Fatalf("recovery directory: %v %v", st, err)195 }196 files, _ := filepath.Glob(filepath.Join(rdir, "book-*.012"))197 if len(files) != 1 {198 t.Fatalf("recovery files %v", files)199 }200 if st, _ := os.Stat(files[0]); st.Mode().Perm() != 0o600 || a1(t, files[0]) != "42" {201 t.Errorf("recovery file %v, A1 %q", st.Mode(), a1(t, files[0]))202 }203}204205// Stopping the server keeps each session's unsaved changes and says so;206// the next session opening the file offers them back, and saving them207// removes the recovery file.208func TestRecoveryOnShutdown(t *testing.T) {209 key := newKey(t)210 srv, addr, dir := testServer(t, nil, key)211 saveSheet(t, filepath.Join(dir, "book.012"), "saved")212 c, err := dial(addr, key, srv.HostKey())213 if err != nil {214 t.Fatal(err)215 }216 tm, err := openExec(t, c, "book.012")217 if err != nil {218 t.Fatal(err)219 }220 tm.waitFor(t, "saved")221 io.WriteString(tm.stdin, "unsaved\r")222 tm.waitFor(t, "modified")223 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)224 defer cancel()225 if err := srv.Shutdown(ctx); err != nil {226 t.Fatal(err)227 }228 tm.waitClosed(t)229 if out := tm.output(); !strings.Contains(out, "the server is stopping") || !strings.Contains(out, "open book.012 again") {230 t.Fatalf("no note: %q", out[max(0, len(out)-300):])231 }232 c.Close()233234 srv, addr, _ = testServer(t, func(o *Options) { o.Dir = dir }, key)235 c, err = dial(addr, key, srv.HostKey())236 if err != nil {237 t.Fatal(err)238 }239 defer c.Close()240 tm = openTerm(t, c, 90, 24)241 tm.waitFor(t, "READY")242 io.WriteString(tm.stdin, "\x0f") // Ctrl+O243 tm.waitFor(t, "Open file")244 io.WriteString(tm.stdin, "book\r")245 tm.waitFor(t, "Unsaved changes to book.012 were kept.", "Restore")246 io.WriteString(tm.stdin, "\r")247 tm.waitFor(t, "unsaved", "Restored the kept changes")248 if got := a1(t, filepath.Join(dir, "book.012")); got != "saved" {249 t.Fatalf("restoring wrote the file: %q", got)250 }251 io.WriteString(tm.stdin, "\x13")252 deadline := time.Now().Add(5 * time.Second)253 for a1(t, filepath.Join(dir, "book.012")) != "unsaved" && time.Now().Before(deadline) {254 time.Sleep(10 * time.Millisecond)255 }256 files, _ := filepath.Glob(filepath.Join(dir, ".012-recovery", "*"))257 if a1(t, filepath.Join(dir, "book.012")) != "unsaved" || len(files) != 0 {258 t.Errorf("after saving: A1 %q, recovery files %v", a1(t, filepath.Join(dir, "book.012")), files)259 }260 io.WriteString(tm.stdin, "\x11")261 tm.waitClosed(t)262}263264// A session that stopped on an internal error keeps its unsaved work,265// however it ended, and tells the client the report is in the log.266func TestRecoveryOnCrash(t *testing.T) {267 dir := t.TempDir()268 root, err := confine.New(dir)269 if err != nil {270 t.Fatal(err)271 }272 m := ui.New(sheet.New(), "book.012")273 m.Serve(root, nil)274 m.Update(tea.KeyPressMsg{Code: '7', Text: "7"})275 m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})276 e := ending{crash: &ui.Crash{Where: "update", Value: "boom"}}277 e.keep(ui.Guard(m))278 var out strings.Builder279 e.tell(&out, time.Minute, root)280 if e.err != nil || !strings.HasPrefix(e.kept, filepath.Join(".012-recovery", "book-")) {281 t.Fatalf("kept %q, %v", e.kept, e.err)282 }283 if !strings.Contains(out.String(), "stopped on an internal error; the server's log has a report") ||284 !strings.Contains(out.String(), "open book.012 again to restore them") {285 t.Errorf("told %q", out.String())286 }287}