1package serve23import (4 "bytes"5 "context"6 "crypto/ed25519"7 "crypto/rand"8 "encoding/base64"9 "errors"10 "io"11 "net"12 "os"13 "path/filepath"14 "strings"15 "sync"16 "sync/atomic"17 "testing"18 "time"1920 typesafe "github.com/FelineStateMachine/typesafe-go"21 "github.com/charmbracelet/x/ansi"22 gossh "golang.org/x/crypto/ssh"2324 "src.rest/cat/012/internal/sheet"25)2627// newKey makes a client key.28func newKey(t testing.TB) gossh.Signer {29 t.Helper()30 _, priv, err := ed25519.GenerateKey(rand.Reader)31 if err != nil {32 t.Fatal(err)33 }34 s, err := gossh.NewSignerFromKey(priv)35 if err != nil {36 t.Fatal(err)37 }38 return s39}4041// testServer serves a fresh directory on a random loopback port to the42// given keys, returning the server, its address and the served43// directory.44func testServer(t testing.TB, change func(*Options), keys ...gossh.Signer) (*Server, string, string) {45 t.Helper()46 top := t.TempDir()47 dir := filepath.Join(top, "served")48 os.Mkdir(dir, 0o755)49 var ak bytes.Buffer50 for _, k := range keys {51 ak.Write(gossh.MarshalAuthorizedKey(k.PublicKey()))52 }53 akPath := filepath.Join(top, "authorized_keys")54 if err := os.WriteFile(akPath, ak.Bytes(), 0o600); err != nil {55 t.Fatal(err)56 }57 o := Defaults()58 o.Dir, o.AuthorizedKeys, o.HostKey, o.Listen = dir, akPath, filepath.Join(top, "config", "host_key"), "127.0.0.1:0"59 if change != nil {60 change(&o)61 }62 srv, err := New(o, Deps{})63 if err != nil {64 t.Fatal(err)65 }66 l, err := net.Listen("tcp", o.Listen)67 if err != nil {68 t.Fatal(err)69 }70 done := make(chan error, 1)71 go func() { done <- srv.Serve(l) }()72 t.Cleanup(func() {73 srv.Close()74 if err := <-done; err != nil {75 t.Errorf("Serve: %v", err)76 }77 })78 return srv, l.Addr().String(), srv.Dir()79}8081func dial(addr string, key gossh.Signer, host gossh.PublicKey) (*gossh.Client, error) {82 return gossh.Dial("tcp", addr, &gossh.ClientConfig{83 User: "dami",84 Auth: []gossh.AuthMethod{gossh.PublicKeys(key)},85 HostKeyCallback: gossh.FixedHostKey(host),86 Timeout: 5 * time.Second,87 })88}8990// term is a client session with a terminal, collecting what it's sent.91// term is a client's terminal: it keeps what the session writes, and92// answers the device attributes as a terminal would, which 012 waits for93// before it draws (ui/termbg.go).94type term struct {95 sess *gossh.Session96 stdin io.WriteCloser97 mu sync.Mutex98 out bytes.Buffer99 done chan error100 wrote chan struct{} // signalled after output arrives101}102103func openTerm(t testing.TB, c *gossh.Client, w, h int) *term {104 t.Helper()105 sess, err := c.NewSession()106 if err != nil {107 t.Fatal(err)108 }109 if err := sess.RequestPty("xterm-256color", h, w, gossh.TerminalModes{}); err != nil {110 t.Fatal(err)111 }112 tm := &term{sess: sess, done: make(chan error, 1), wrote: make(chan struct{}, 1)}113 tm.stdin, _ = sess.StdinPipe()114 sess.Stdout = tm115 if err := sess.Shell(); err != nil {116 t.Fatal(err)117 }118 go func() { tm.done <- sess.Wait() }()119 return tm120}121122func (tm *term) Write(p []byte) (int, error) {123 if bytes.Contains(p, []byte(ansi.RequestPrimaryDeviceAttributes)) {124 go io.WriteString(tm.stdin, "\x1b[?62;22c")125 }126 tm.mu.Lock()127 n, err := tm.out.Write(p)128 tm.mu.Unlock()129 select {130 case tm.wrote <- struct{}{}:131 default:132 }133 return n, err134}135136func (tm *term) output() string {137 tm.mu.Lock()138 defer tm.mu.Unlock()139 return tm.out.String()140}141142// waitFor waits until the output holds each of want.143func (tm *term) waitFor(t testing.TB, want ...string) {144 t.Helper()145 deadline := time.Now().Add(10 * time.Second)146 for time.Now().Before(deadline) {147 out := tm.output()148 ok := true149 for _, w := range want {150 ok = ok && strings.Contains(out, w)151 }152 if ok {153 return154 }155 time.Sleep(10 * time.Millisecond)156 }157 t.Fatalf("output never showed %q; got %q", want, tm.output())158}159160// waitClosed waits for the session to end.161func (tm *term) waitClosed(t testing.TB) {162 t.Helper()163 select {164 case <-tm.done:165 case <-time.After(10 * time.Second):166 t.Fatal("the session didn't close")167 }168}169170// A session gets its own 012: the first screen renders at the client's171// size, and quitting ends the session.172func TestSession(t *testing.T) {173 key := newKey(t)174 srv, addr, dir := testServer(t, nil, key)175 c, err := dial(addr, key, srv.HostKey())176 if err != nil {177 t.Fatal(err)178 }179 defer c.Close()180 tm := openTerm(t, c, 90, 20)181 tm.waitFor(t, "READY", "012 - untitled", "Sheet1")182 if n := srv.Sessions(); n != 1 {183 t.Errorf("Sessions() = %d", n)184 }185186 // Type a number and save it: the file lands in the served directory.187 io.WriteString(tm.stdin, "42\r")188 // Copying goes to the client's clipboard, through its terminal189 // (OSC 52), not the server's.190 io.WriteString(tm.stdin, "\x1b[A\x03")191 tm.waitFor(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte("42")))192 io.WriteString(tm.stdin, "\x13") // Ctrl+S asks for a name193 tm.waitFor(t, "Save as")194 io.WriteString(tm.stdin, "kept\r")195 waitFile(t, filepath.Join(dir, "kept.012"))196197 io.WriteString(tm.stdin, "\x11") // Ctrl+Q: saved, so it quits at once198 tm.waitClosed(t)199 deadline := time.Now().Add(5 * time.Second)200 for srv.Sessions() != 0 && time.Now().Before(deadline) {201 time.Sleep(10 * time.Millisecond)202 }203 if n := srv.Sessions(); n != 0 {204 t.Errorf("Sessions() = %d after quitting", n)205 }206}207208func waitFile(t *testing.T, path string) {209 t.Helper()210 deadline := time.Now().Add(5 * time.Second)211 for time.Now().Before(deadline) {212 if f, err := os.Open(path); err == nil {213 s, err := sheet.Read(f)214 f.Close()215 if err == nil && s.Cell(sheet.Addr{}) != nil && s.Cell(sheet.Addr{}).Input == "42" {216 return217 }218 }219 time.Sleep(10 * time.Millisecond)220 }221 t.Fatalf("%s wasn't saved", path)222}223224// A key missing from authorized_keys can't log in, and neither can a225// password.226func TestRejected(t *testing.T) {227 good, bad := newKey(t), newKey(t)228 srv, addr, _ := testServer(t, nil, good)229 if c, err := dial(addr, bad, srv.HostKey()); err == nil {230 c.Close()231 t.Fatal("a key not in authorized_keys logged in")232 }233 c, err := gossh.Dial("tcp", addr, &gossh.ClientConfig{234 User: "dami",235 Auth: []gossh.AuthMethod{gossh.Password("hunter2"), gossh.KeyboardInteractive(236 func(string, string, []string, []bool) ([]string, error) { return nil, nil })},237 HostKeyCallback: gossh.FixedHostKey(srv.HostKey()),238 Timeout: 5 * time.Second,239 })240 if err == nil {241 c.Close()242 t.Fatal("password auth logged in")243 }244}245246// Logged in, a client can run 012 and nothing else: no commands, no247// subsystems, no forwarding, no session without a terminal.248func TestOnlyShell(t *testing.T) {249 key := newKey(t)250 srv, addr, _ := testServer(t, nil, key)251 c, err := dial(addr, key, srv.HostKey())252 if err != nil {253 t.Fatal(err)254 }255 defer c.Close()256257 sess, _ := c.NewSession()258 if out, err := sess.CombinedOutput("cat /etc/passwd"); err == nil || len(out) > 0 {259 t.Errorf("exec ran: %q, %v", out, err)260 }261 sess, _ = c.NewSession()262 if err := sess.RequestSubsystem("sftp"); err == nil {263 t.Error("the sftp subsystem started")264 }265 if conn, err := c.Dial("tcp", "127.0.0.1:22"); err == nil {266 conn.Close()267 t.Error("local port forwarding was allowed")268 }269 if l, err := c.Listen("tcp", "127.0.0.1:0"); err == nil {270 l.Close()271 t.Error("remote port forwarding was allowed")272 }273 // A shell without a terminal is told to ask for one and closed.274 sess, _ = c.NewSession()275 var stderr bytes.Buffer276 sess.Stderr = &stderr277 sess.Stdout = io.Discard278 if err := sess.Shell(); err != nil {279 t.Fatal(err)280 }281 var exit *gossh.ExitError282 if err := sess.Wait(); !errors.As(err, &exit) || exit.ExitStatus() != 1 || !strings.Contains(stderr.String(), "needs a terminal") {283 t.Errorf("shell without a terminal: %v, %q", err, stderr.String())284 }285}286287// Sessions past the limit are turned away; a place frees when one ends.288func TestMaxSessions(t *testing.T) {289 key := newKey(t)290 srv, addr, _ := testServer(t, func(o *Options) { o.MaxSessions = 1 }, key)291 c, err := dial(addr, key, srv.HostKey())292 if err != nil {293 t.Fatal(err)294 }295 defer c.Close()296 first := openTerm(t, c, 80, 24)297 first.waitFor(t, "READY")298 second := openTerm(t, c, 80, 24)299 second.waitClosed(t)300 io.WriteString(first.stdin, "\x11")301 first.waitClosed(t)302 third := openTerm(t, c, 80, 24)303 third.waitFor(t, "READY")304}305306// A session without input for the idle timeout is closed with a note.307func TestIdleTimeout(t *testing.T) {308 key := newKey(t)309 srv, addr, _ := testServer(t, func(o *Options) { o.IdleTimeout = 300 * time.Millisecond }, key)310 c, err := dial(addr, key, srv.HostKey())311 if err != nil {312 t.Fatal(err)313 }314 defer c.Close()315 tm := openTerm(t, c, 80, 24)316 tm.waitFor(t, "READY")317 tm.waitClosed(t)318 if !strings.Contains(tm.output(), "closed after 300ms without input") {319 t.Errorf("no idle note in %q", tm.output()[max(0, len(tm.output())-200):])320 }321}322323// fakeJEV answers every question with 0.9 (90%), slowly enough324// that two sessions' questions are in flight together.325type fakeJEV struct{ calls atomic.Int64 }326327func (f *fakeJEV) SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {328 f.calls.Add(1)329 time.Sleep(50 * time.Millisecond)330 return &typesafe.SystemOneResponse{Answers: map[string]typesafe.Answer{"answer": typesafe.NoulAnswer{Noul: 0.9}}}, nil331}332333// Two sessions are two separate spreadsheets: what one types, the other334// doesn't see, and each gets the answers to its own JEV questions.335func TestSessionsAreSeparate(t *testing.T) {336 key := newKey(t)337 fake := &fakeJEV{}338 srv, addr, _ := testServer(t, nil, key)339 srv.jev = fake340 c, err := dial(addr, key, srv.HostKey())341 if err != nil {342 t.Fatal(err)343 }344 defer c.Close()345 a, b := openTerm(t, c, 80, 24), openTerm(t, c, 100, 30)346 a.waitFor(t, "READY")347 b.waitFor(t, "READY")348 io.WriteString(a.stdin, "only in a\r")349 a.waitFor(t, "only in a")350 for _, tm := range []*term{a, b} {351 io.WriteString(tm.stdin, `=JEV.PROB("the box was crushed", "Is this a complaint?")`+"\r")352 }353 a.waitFor(t, "90%")354 b.waitFor(t, "90%")355 if strings.Contains(b.output(), "only in a") {356 t.Error("session b shows what session a typed")357 }358 if n := fake.calls.Load(); n != 2 {359 t.Errorf("%d JEV requests; want one per session", n)360 }361 for _, tm := range []*term{a, b} {362 io.WriteString(tm.stdin, "\x11")363 tm.waitFor(t, "unsaved changes")364 io.WriteString(tm.stdin, "d")365 tm.waitClosed(t)366 }367}