cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Merge branch 'ssh-serve'

Dami committed 117cd0d
commit
117cd0d605ba7afd1ce564c8b6c6733f88835763
parent
36bfe92db677aa22b9c5717c013429d2a9dadada
parent
4043de875021d9d0cb7fa51051e391d6464b4d4e
author
Dami <dami.etoile@gmail.com>
32 files changed, +2441 -114
MREADME.md2+
MROADMAP.md6+-
Mcmd/012/main.go4+
Acmd/012/serve.go129+++++++++++
Acmd/012/serve_test.go27++
Mdocs/README.md1+
Mdocs/architecture.md24++-
Mdocs/files.md5+-
Mdocs/limits.md35+++-
Adocs/ssh.md139++++++++++++
Mdocs/testing.md4+
Me2e/harness_test.go9+-
Ae2e/ssh_test.go110+++++++++
Mgo.mod27++-
Mgo.sum72++++--
Ainternal/confine/confine.go134+++++++++++
Ainternal/confine/confine_test.go144++++++++++++
Ainternal/serve/keys.go155+++++++++++++
Ainternal/serve/keys_test.go126+++++++++++
Ainternal/serve/options.go75++++++
Ainternal/serve/serve.go296+++++++++++++++++++++++++
Ainternal/serve/serve_test.go360++++++++++++++++++++++++++++++
Ainternal/serve/stress_test.go151+++++++++++++
Minternal/ui/download.go28+-
Minternal/ui/file.go213+++++++++++++++---
Minternal/ui/graphics.go7+-
Minternal/ui/model.go22+-
Ainternal/ui/serve_test.go189++++++++++++++++
Minternal/ui/transfer.go41++-
Moracle/go.mod6+-
Moracle/go.sum12+-
Mscripts/stress/run.sh2+-
M README.md +2 -0
M ROADMAP.md +4 -2
@@ -76,7 +76,7 @@ Avoid in the main binary: DuckDB and automerge (both need cgo).
7676 - Multiple sheets with references between them, in Sheets' style (`Sheet2!A1`) rather than 1-2-3 R3's `B:A1`, and sheet tabs. Done.7777 - Pivot tables as derived sheets. L.7878 - Macros: replay the command log; Starlark (`go.starlark.net`) for scripts, with step limits.79-- Serve over SSH with `charm.land/wish/v2`.79+- Serve over SSH with `charm.land/wish/v2`. Done: `012 serve [dir]`, a 012 per session confined to the directory, public-key auth only ([docs/ssh.md](docs/ssh.md)). Not shared editing: see below.8080 - Decimal mode for currency (`cockroachdb/apd/v3`), opt-in. Done: File > Settings > Decimal arithmetic, boundary in the README.8181 - Demos with VHS tapes. Done: `make demos` renders `demos/` locally and the README shows them. Not run in CI: the GitHub repo is hosting only.8282
@@ -101,7 +101,9 @@ Raise it in measured steps (see [docs/limits.md](docs/limits.md)):
101101 102102 ## Later: sharing a live sheet (shelved)103103 104-Explored, not scheduled. One session host that runs a Bubble Tea program104+Explored, not scheduled. `012 serve` gives each SSH session its own105+spreadsheet; saving over a file another session saved asks first, and106+that is all the sessions know of each other. One session host that runs a Bubble Tea program105107 for any byte stream with window-size events, fed by SSH106108 (`charm.land/wish/v2`), iroh tickets (the Go transport in107109 `FelineStateMachine/allons` `local/transport/iroh`, which needs cgo and a
M cmd/012/main.go +4 -0
A cmd/012/serve.go +129 -0
@@ -0,0 +1,129 @@
1+package main2+3+import (4+ "context"5+ "errors"6+ "flag"7+ "fmt"8+ "io"9+ "log/slog"10+ "net"11+ "os"12+ "os/signal"13+ "syscall"14+ "time"15+16+ gossh "golang.org/x/crypto/ssh"17+18+ "github.com/FelineStateMachine/012/internal/jev"19+ "github.com/FelineStateMachine/012/internal/serve"20+)21+22+const serveUsage = "usage: 012 serve [--listen addr] [--authorized-keys file] [--host-key file] [--idle-timeout 30m] [--max-sessions 8] [dir]"23+24+// serveFlags parses 012 serve's arguments into options, starting from25+// the defaults. The served directory may come before or after the flags.26+func serveFlags(args []string, stderr io.Writer) (serve.Options, error) {27+ o := serve.Defaults()28+ fs := flag.NewFlagSet("012 serve", flag.ContinueOnError)29+ fs.SetOutput(stderr)30+ fs.Usage = func() {31+ fmt.Fprintln(stderr, serveUsage)32+ fs.PrintDefaults()33+ }34+ fs.StringVar(&o.Listen, "listen", o.Listen, "address to listen on")35+ fs.StringVar(&o.AuthorizedKeys, "authorized-keys", o.AuthorizedKeys, "public keys allowed to log in, in authorized_keys format")36+ fs.StringVar(&o.HostKey, "host-key", o.HostKey, "the server's private key, generated if missing")37+ fs.DurationVar(&o.IdleTimeout, "idle-timeout", o.IdleTimeout, "end sessions without input for this long (0: never)")38+ fs.IntVar(&o.MaxSessions, "max-sessions", o.MaxSessions, "most sessions at once")39+ var dirs []string40+ for {41+ if err := fs.Parse(args); err != nil {42+ return o, err43+ }44+ if fs.NArg() == 0 {45+ break46+ }47+ dirs, args = append(dirs, fs.Arg(0)), fs.Args()[1:]48+ }49+ switch len(dirs) {50+ case 0:51+ case 1:52+ o.Dir = dirs[0]53+ default:54+ return o, errors.New(serveUsage)55+ }56+ return o, nil57+}58+59+// runServe is 012 serve: sessions over SSH until interrupted.60+func runServe(args []string) error {61+ tc, args, err := telemetryFlags(args)62+ if err != nil {63+ return err64+ }65+ o, err := serveFlags(args, os.Stderr)66+ if errors.Is(err, flag.ErrHelp) {67+ return nil68+ }69+ if err != nil {70+ return err71+ }72+ stopTelemetry, err := startTelemetry(tc)73+ if err != nil {74+ return err75+ }76+ defer stopTelemetry()77+ log := slog.New(slog.NewTextHandler(os.Stderr, nil))78+ d := serve.Deps{Log: log}79+ // JEV functions run when the server's own environment has an API80+ // key; sessions never read .env files.81+ if cfg, ok := jev.LoadConfig(); ok {82+ if d.JEV, err = jev.NewClient(cfg); err != nil {83+ return fmt.Errorf("JEV: %w", err)84+ }85+ }86+ srv, err := serve.New(o, d)87+ if err != nil {88+ return err89+ }90+ l, err := net.Listen("tcp", o.Listen)91+ if err != nil {92+ return err93+ }94+ if host, _, err := net.SplitHostPort(o.Listen); err == nil {95+ if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {96+ log.Warn("listening beyond this machine: anyone holding an authorized key who can reach " + o.Listen + " can read and write " + srv.Dir())97+ }98+ }99+ fmt.Fprintf(os.Stderr, "012 serving %s on %s\nhost key %s\nconnect: ssh -p %s %s\n",100+ srv.Dir(), l.Addr(), gossh.FingerprintSHA256(srv.HostKey()), portOf(l.Addr()), hostOf(l.Addr()))101+102+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)103+ defer stop()104+ done := make(chan error, 1)105+ go func() { done <- srv.Serve(l) }()106+ select {107+ case err := <-done:108+ return err109+ case <-ctx.Done():110+ }111+ // Stop listening, give sessions a moment, then drop what's left.112+ log.Info("stopping", "sessions", srv.Sessions())113+ sctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)114+ defer cancel()115+ if err := srv.Shutdown(sctx); err != nil {116+ srv.Close()117+ }118+ return <-done119+}120+121+func portOf(a net.Addr) string {122+ _, port, _ := net.SplitHostPort(a.String())123+ return port124+}125+126+func hostOf(a net.Addr) string {127+ host, _, _ := net.SplitHostPort(a.String())128+ return host129+}
A cmd/012/serve_test.go +27 -0
M docs/README.md +1 -0
M docs/architecture.md +23 -1
@@ -4,7 +4,7 @@
44 Lip Gloss v2.55 66 ```7-cmd/012 entry point: flags, JEV setup, opening or importing a file7+cmd/012 entry point: flags, JEV setup, opening or importing a file, 012 serve88 internal/sheet the engine: cells, evaluation, functions, recalculation, undo, files99 internal/formula the formula language: references, lexer, parser, printer, rewriting1010 internal/numfmt number formats, rounding, General, date serials
@@ -12,6 +12,8 @@ internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk1
1212 internal/chart chart layout, text rendering and kitty image encoding1313 internal/jev JEV configuration, answer cache and TypeSafe client1414 internal/telemetry opt-in JSON log and OTLP export of spans, events and frame stats15+internal/serve the SSH server: auth, host key, a Model per session (charm.land/wish/v2)16+internal/confine resolving typed file names, confined to a directory when served1517 internal/stress synthetic worst-case sheets for the -tags stress benchmarks1618 internal/ui the Bubble Tea model: modes, menus, overlays, rendering1719 theme style roles and the widgets drawn with them (frames, key chips)
@@ -203,6 +205,26 @@ saved with sheets. Work is bounded by the chart's size, not its data: only
203205 the categories that fit are drawn, pie slices are found by binary search,204206 and a pie image supersamples only pixels on a slice edge or the rim.205207 208+## Serving over SSH209+210+`internal/serve` wraps `charm.land/ssh` (wish's server) with only a211+session channel, public-key auth against authorized_keys and a shell212+request allowed. Each session builds a `ui.Model` of its own and runs it213+in its own `tea.Program` over the session (wish's emulated PTY), with214+the client's environment and window size; `Model.Serve` gives it the215+client's environment for terminal detection and a `confine.Root` for216+file names. Everything a model knows lives in the model, so sessions217+share nothing but read-only tables (the command and function218+registries), the process-wide telemetry and, with JEV on, the HTTP219+client; each gets its own `jev.Cache`, whose queue belongs to that220+session's program. See [ssh.md](ssh.md).221+222+**File names.** The UI keeps names as typed and turns them into paths223+only to read or write, through the model's `confine.Root`: the zero224+root (the local app) uses them as they are, a served session's root225+keeps them inside its directory. Every open, save, import, download and226+file listing goes through `Model.path` or the root.227+206228 ## Telemetry207229 208230 `internal/telemetry` is off unless asked for. When on, spans, events and
M docs/files.md +4 -1
M docs/limits.md +34 -1
@@ -29,6 +29,7 @@ it lags, and past a second it stalls.
2929 | JEV | Up to about 1000 JEV cells: 0.3 ms of CPU per answer | 4000 JEV cells: 1.3 ms per answer, 5 s of CPU to answer them all | | Every answer recalculates every JEV cell (they're volatile) |3030 | Formula depth | 10,000 nested parentheses or IFs: under 5 ms | | No explicit limit; recursion grows the stack | Recursive parser and evaluator |3131 | Find, filter, sort, fill | Filter or sort 8191 rows: 1 to 32 ms; find over 213 k cells: 38 ms; fill 8192 rows: 4 ms | Replace all over 213 k cells: 280 ms | | Per-cell string conversion and regexp |32+| SSH sessions (`012 serve`) | 50 sessions typing at once: frames within one frame interval (p95 16.6 ms), 1.3 MiB per session plus its sheets (upper bound) | | More than `--max-sessions` (8 by default): turned away | Bubble Tea's 60 fps pacing; per session, the terminal's cell buffers |3233 3334 ## Sheet size3435
@@ -187,6 +188,37 @@ Cell reads now go through one more function call to resolve the sheet,
187188 which is most of the 3 to 5% on read-heavy shapes; undo steps key their188189 before-images by sheet and address, which is BigUndo's extra allocation.189190 191+## Serving over SSH192+193+`BenchmarkSessions` (`internal/serve/stress_test.go`) opens N sessions,194+each over its own SSH connection on loopback at 120 x 40, optionally195+opening the same 1000 x 26 sheet of numbers in every one, then has every196+session press an arrow key at once, 200 times, timing each key from the197+client's write to the first bytes of its frame arriving. The clients198+run in the same process, so the heap and CPU figures include their side199+of the connections and are upper bounds for the server. Medians of 3200+runs, `-benchtime 200x`:201+202+| Sessions, sheet | Heap per session | Key to frame p50 / p95 / max | CPU per frame |203+|---|---|---|---|204+| 10, new sheet | 1.30 MiB | 10.7 / 16.6 / 17.1 ms | 0.95 ms |205+| 10, 1000 x 26 numbers | 8.5 MiB | 11.2 / 16.6 / 18.0 ms | 1.15 ms |206+| 50, new sheet | 1.30 MiB | 9.2 / 16.3 / 19.2 ms | 0.53 ms |207+| 50, 1000 x 26 numbers | 8.5 MiB | 9.4 / 16.7 / 20.5 ms | 0.69 ms |208+209+Latency doesn't move from 10 to 50 sessions: it's Bubble Tea's frame210+pacing (at most one frame every 16.7 ms, so a key waits half a frame on211+average), not load. A heap profile with 50 sessions open212+(`SERVE_HEAP_PROFILE=file`) puts three quarters of a session's heap in213+Bubble Tea's render buffers (about 1 MiB at 120 x 40, growing with the214+window), 130 KiB in its input key table and under 50 KiB in both ends215+of the SSH connection, so the figure is close to the server's own. On216+top come about 300 B per cell of the sheets a session has open;217+sessions opening the same file each hold a copy. CPU per frame covers both ends of the connection (the218+encryption twice, the client reading the frame); at 0.7 ms, 50 sessions219+typing continuously at 60 frames a second would keep about two cores220+busy.221+190222 ## Hotspots found and fixed191223 192224 Each with its benchmark before and after, in the commit that fixed it.
@@ -271,7 +303,8 @@ fast. They live in `internal/sheet/stress_test.go` (engine),
271303 `internal/sheet/hotpath_stress_test.go` (parsing and number formats),272304 `internal/ui/stress_test.go` (View, frames, keystrokes, JEV, telemetry273305 overhead), `internal/fileio/stress_test.go` (imports and exports),274-`internal/chart/stress_test.go` (charts as text and images) and306+`internal/chart/stress_test.go` (charts as text and images),307+`internal/serve/stress_test.go` (concurrent SSH sessions) and275308 `e2e/stress_test.go`; the synthetic sheets are built by276309 `internal/stress`. Results accumulate in `.deps/stress/results` and, with277310 the observability stack up, in ClickHouse.
A docs/ssh.md +139 -0
@@ -0,0 +1,139 @@
1+# Serving over SSH2+3+`012 serve` runs an SSH server that gives every session its own 012, in4+the client's own terminal, on the files of one directory. It's for5+reaching your sheets from another machine or a tablet with an SSH app,6+without installing anything there. It is not shared editing: two7+sessions are two separate spreadsheets (see8+[the roadmap](../ROADMAP.md#later-sharing-a-live-sheet-shelved)).9+10+```sh11+012 serve ~/sheets # serve ~/sheets on 127.0.0.1:231212+ssh -p 2312 localhost # from this machine13+```14+15+```16+012 serve [--listen addr] [--authorized-keys file] [--host-key file]17+ [--idle-timeout 30m] [--max-sessions 8] [--log file] [--otlp url] [dir]18+```19+20+| Flag | Default | What |21+|---|---|---|22+| `dir` | `.` | The served directory. Sessions open, save, import and download only inside it |23+| `--listen` | `127.0.0.1:2312` | Address to listen on. Loopback only unless you change it |24+| `--authorized-keys` | `~/.ssh/authorized_keys` | Public keys allowed in, in OpenSSH's format |25+| `--host-key` | `<config dir>/012/ssh_host_ed25519_key` | The server's key, generated on first run |26+| `--idle-timeout` | `30m` | End a session after this long without input; `0` never does |27+| `--max-sessions` | `8` | Sessions at once; more are turned away with a message |28+| `--log`, `--otlp` | off | Telemetry, as for the app ([observability.md](observability.md)) |29+30+The config directory is `os.UserConfigDir()`: `~/Library/Application31+Support` on macOS, `~/.config` on Linux. On start, 012 prints the served32+directory, the address, the host key's fingerprint and the command to33+connect.34+35+## Setup36+37+1. Put the public keys that may connect in an authorized_keys file. Your38+ own `~/.ssh/authorized_keys` works, or keep a separate one for 012:39+40+ ```sh41+ cat ~/.ssh/id_ed25519.pub > ~/.config/012/authorized_keys42+ chmod 600 ~/.config/012/authorized_keys43+ 012 serve --authorized-keys ~/.config/012/authorized_keys ~/sheets44+ ```45+46+2. Connect with `ssh -p 2312 host`. The first time, check the host key47+ fingerprint ssh shows against the one 012 printed, or add the key to48+ `known_hosts` yourself from `ssh_host_ed25519_key.pub` next to it.49+50+3. To reach it from another machine, prefer keeping 012 on loopback and51+ tunnelling through the machine's own sshd52+ (`ssh -L 2312:127.0.0.1:2312 host`, then `ssh -p 2312 localhost`), or53+ a private network such as Tailscale with `--listen 100.x.y.z:2312`.54+ Listening beyond loopback prints a warning.55+56+Stop the server with Ctrl+C. Running sessions end with it, and unsaved57+changes in them are lost.58+59+## Security model60+61+The server exposes the files of one directory to whoever holds one of62+the authorized keys. Everything else is closed.63+64+- **Authentication.** Public keys only, checked against the65+ authorized_keys file, which is read again on every login so edits66+ apply at once. No passwords, no keyboard-interactive, no certificates.67+ The file must not be writable by group or others, as with sshd's68+ `StrictModes`. Keys carrying options 012 can't honor (`command=`,69+ `from=`, `no-pty`, `restrict` without `pty`, `expiry-time=`,70+ `cert-authority` and so on) are skipped, so a key restricted to, say,71+ a backup command never gets a spreadsheet. The user name is logged but72+ not checked.73+- **Host key.** An ed25519 key generated on first run, written `0600`74+ in a `0700` directory. 012 refuses to start if the key is readable by75+ others.76+- **What a session can do.** Run 012, nothing else. Exec requests77+ (`ssh host cat /etc/passwd`), subsystems (`sftp`, `scp`), local and78+ remote port forwarding and X11 are refused; agent forwarding requests79+ are accepted by the SSH library but never used. A session without a80+ terminal is told to use `ssh -t` and closed.81+- **Files.** Names typed in File > Open, Save as, Import and Download82+ resolve inside the served directory. Refused, with "outside the served83+ directory": absolute paths, `..` that climbs out, hidden files and84+ directories (`.env`, `.git`, `.ssh`), and symbolic links that lead85+ outside or nowhere. Links that stay inside work. Error messages name86+ files relative to the served directory, not where it is on the87+ server. Files are written with the server's user and permissions.88+- **JEV.** On only when the server process's own environment has89+ `TYPESAFE_API_KEY`; sessions never read `.env` files. Every session90+ has its own answer cache, and all of them spend the server's key.91+- **Logs.** Logins, rejected keys, failed handshakes, sessions turned92+ away and session ends go to stderr and to telemetry as `ssh.*` events93+ with the user name, the key's SHA256 fingerprint and the remote94+ address, never file contents or cells.95+96+| Event | When | Attributes |97+|---|---|---|98+| `ssh.session` | a session starts | `user`, `key`, `remote`, `term`, `width`, `height` |99+| `ssh.session_end` | it ends | `user`, `key`, `remote`, `duration`, `idle` (closed for idling) |100+| `ssh.rejected` | a key not in authorized_keys | `user`, `key`, `remote` |101+| `ssh.full` | a session turned away at `--max-sessions` | `user`, `key`, `remote` |102+| `ssh.failed` | a handshake or login fails | `remote`, `error` |103+| `ssh.auth_error` | authorized_keys can't be read or parsed | `error` |104+105+The `frames` summaries and the `ssh_sessions` gauge cover every session106+together: telemetry is per process.107+108+What it doesn't protect against: someone who can already write to the109+served directory on the server (a symbolic link swapped in between the110+check and the open), and anything a holder of an authorized key does111+with the files they're given.112+113+## Each session114+115+Every session is a separate 012 with nothing shared but the process:116+its own workbook, undo history, clipboard, theme, JEV cache and117+terminal state. What it learns about the terminal comes from the118+client: the window size and its changes, the colors (from the `TERM`119+the client sends), the light or dark background, kitty graphics, and120+the clipboard, which Ctrl+C sets on the client's machine through OSC 52.121+122+## Two sessions, one file123+124+Opening the same file in two sessions gives two copies. Saving over a125+file that changed on disk since it was opened or last saved in this126+session (someone else saved it) asks first on the context line:127+Enter overwrites it, S saves under another name, Esc cancels. The same128+check protects the local app from other programs writing the file.129+130+## Limits131+132+- Sessions start on a new sheet; there's no way to open a file from the133+ ssh command line, since commands are refused.134+- An idle session is closed without saving.135+- Each session holds about 1.3 MiB of heap on a new sheet, plus about136+ 300 B per cell of the sheets it opens; two sessions opening one file137+ hold two copies. With 50 sessions typing at once on loopback, frames138+ still arrive within one frame interval (p95 16.6 ms), the same as with139+ 10: see [limits.md](limits.md#serving-over-ssh).
M docs/testing.md +4 -0
M e2e/harness_test.go +8 -1
A e2e/ssh_test.go +110 -0
@@ -0,0 +1,110 @@
1+package e2e2+3+import (4+ "bufio"5+ "os"6+ "os/exec"7+ "path/filepath"8+ "regexp"9+ "strings"10+ "testing"11+ "time"12+)13+14+// serveSSH starts 012 serve on a random loopback port for served,15+// authorizing a new key, and returns the system's ssh client and the16+// arguments that reach the server with it, checking the host key.17+func serveSSH(t *testing.T, top, served string) (string, []string) {18+ t.Helper()19+ sshPath, err1 := exec.LookPath("ssh")20+ keygen, err2 := exec.LookPath("ssh-keygen")21+ if err1 != nil || err2 != nil {22+ t.Skip("needs ssh and ssh-keygen")23+ }24+ id := filepath.Join(top, "id_ed25519")25+ if out, err := exec.Command(keygen, "-q", "-t", "ed25519", "-N", "", "-C", "e2e", "-f", id).CombinedOutput(); err != nil {26+ t.Fatalf("ssh-keygen: %v\n%s", err, out)27+ }28+ pub, err := os.ReadFile(id + ".pub")29+ if err != nil {30+ t.Fatal(err)31+ }32+ ak := filepath.Join(top, "authorized_keys")33+ if err := os.WriteFile(ak, pub, 0o600); err != nil {34+ t.Fatal(err)35+ }36+ hostKey := filepath.Join(top, "config", "host_key")37+ srv := exec.Command(binPath, "serve", "--listen", "127.0.0.1:0", "--authorized-keys", ak, "--host-key", hostKey, served)38+ srv.Env = append(os.Environ(), "TYPESAFE_API_KEY=", "TYPESAFE_BASE_URL=")39+ stderr, err := srv.StderrPipe()40+ if err != nil {41+ t.Fatal(err)42+ }43+ if err := srv.Start(); err != nil {44+ t.Fatal(err)45+ }46+ t.Cleanup(func() {47+ srv.Process.Kill()48+ srv.Wait()49+ })50+ // "012 serving DIR on 127.0.0.1:PORT"51+ port := make(chan string, 1)52+ go func() {53+ re := regexp.MustCompile(` on 127\.0\.0\.1:(\d+)$`)54+ sc := bufio.NewScanner(stderr)55+ for sc.Scan() {56+ if m := re.FindStringSubmatch(sc.Text()); m != nil {57+ port <- m[1]58+ }59+ }60+ }()61+ var p string62+ select {63+ case p = <-port:64+ case <-time.After(waitTimeout):65+ t.Fatal("012 serve didn't start")66+ }67+ hostPub, err := os.ReadFile(hostKey + ".pub")68+ if err != nil {69+ t.Fatal(err)70+ }71+ knownHosts := filepath.Join(top, "known_hosts")72+ os.WriteFile(knownHosts, []byte("[127.0.0.1]:"+p+" "+string(hostPub)), 0o600)73+ return sshPath, []string{"-F", "/dev/null", "-i", id, "-e", "none", "-p", p,74+ "-o", "IdentitiesOnly=yes", "-o", "IdentityAgent=none", "-o", "BatchMode=yes",75+ "-o", "UserKnownHostsFile=" + knownHosts, "-o", "StrictHostKeyChecking=yes", "-o", "LogLevel=ERROR",76+ "-t", "127.0.0.1"}77+}78+79+// 012 serve through a real ssh client in a real terminal: the first80+// screen, typing, resizing, saving inside the served directory, a81+// refused escape, and quitting closing the connection.82+func TestServeOverSSH(t *testing.T) {83+ top := t.TempDir()84+ served := filepath.Join(top, "served")85+ os.Mkdir(served, 0o755)86+ sshPath, args := serveSSH(t, top, served)87+88+ s := startWith(t, options{dir: top, program: sshPath}, args...)89+ s.waitFor("Sheet1")90+ s.keys("hello", "<enter>")91+ s.waitForLine(gridRow1, " 1 hello")92+93+ // The window follows the client's terminal.94+ s.resize(120, 40)95+ s.eventually("column K after resizing", func() bool { return strings.Contains(s.line(3), " K") })96+97+ s.keys("<ctrl+s>", "greeting", "<enter>")98+ s.eventually("greeting.012 in the served directory", func() bool {99+ _, err := os.Stat(filepath.Join(served, "greeting.012"))100+ return err == nil101+ })102+ s.waitFor("greeting.012")103+104+ s.keys("<ctrl+o>", "../id_ed25519", "<enter>")105+ s.waitFor("outside the served directory")106+ s.keys("<esc>")107+108+ s.keys("<ctrl+q>")109+ s.waitExit()110+}
M go.mod +19 -8
@@ -5,49 +5,60 @@ go 1.27.1
55 require (66 charm.land/bubbletea/v2 v2.0.1077 charm.land/lipgloss/v2 v2.0.68+ charm.land/ssh v0.4.39+ charm.land/wish/v2 v2.0.4810 github.com/FelineStateMachine/typesafe-go v0.2.09- github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f9788611+ github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd91012 github.com/charmbracelet/x/ansi v0.11.81113 github.com/cockroachdb/apd/v3 v3.2.31214 github.com/parquet-go/parquet-go v0.32.01315 github.com/sahilm/fuzzy v0.1.31416 github.com/xuri/excelize/v2 v2.11.015- golang.org/x/text v0.38.017+ golang.org/x/crypto v0.57.018+ golang.org/x/text v0.42.01619 modernc.org/sqlite v1.59.01720 )1821 1922 require (23+ charm.land/log/v2 v2.0.1 // indirect2024 github.com/andybalholm/brotli v1.1.1 // indirect25+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect2126 github.com/charmbracelet/colorprofile v0.4.3 // indirect27+ github.com/charmbracelet/keygen v0.5.4 // indirect28+ github.com/charmbracelet/x/conpty v0.2.0 // indirect2229 github.com/charmbracelet/x/term v0.2.2 // indirect2330 github.com/charmbracelet/x/termios v0.1.1 // indirect2431 github.com/charmbracelet/x/windows v0.2.2 // indirect32+ github.com/charmbracelet/x/xpty v0.1.4 // indirect2533 github.com/clipperhouse/displaywidth v0.11.0 // indirect2634 github.com/clipperhouse/uax29/v2 v2.7.0 // indirect35+ github.com/creack/pty v1.1.24 // indirect2736 github.com/dustin/go-humanize v1.0.1 // indirect37+ github.com/go-logfmt/logfmt v0.6.1 // indirect2838 github.com/google/uuid v1.6.0 // indirect2939 github.com/klauspost/compress v1.17.9 // indirect3040 github.com/lucasb-eyer/go-colorful v1.4.1 // indirect3141 github.com/mattn/go-isatty v0.0.24 // indirect32- github.com/mattn/go-runewidth v0.0.24 // indirect42+ github.com/mattn/go-runewidth v0.0.30 // indirect3343 github.com/muesli/cancelreader v0.2.2 // indirect3444 github.com/ncruces/go-strftime v1.0.0 // indirect3545 github.com/parquet-go/bitpack v1.0.0 // indirect3646 github.com/parquet-go/jsonlite v1.0.0 // indirect3747 github.com/pierrec/lz4/v4 v4.1.21 // indirect48+ github.com/pires/go-proxyproto v0.15.0 // indirect3849 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect3950 github.com/richardlehane/mscfb v1.0.7 // indirect4051 github.com/richardlehane/msoleps v1.0.6 // indirect4152 github.com/rivo/uniseg v0.4.7 // indirect4253 github.com/tiendc/go-deepcopy v1.7.2 // indirect4354 github.com/twpayne/go-geom v1.6.1 // indirect44- github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect55+ github.com/xo/terminfo v1.0.0 // indirect4556 github.com/xuri/efp v0.0.1 // indirect4657 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect47- golang.org/x/crypto v0.53.0 // indirect48- golang.org/x/net v0.56.0 // indirect49- golang.org/x/sync v0.22.0 // indirect50- golang.org/x/sys v0.47.0 // indirect58+ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect59+ golang.org/x/net v0.59.0 // indirect60+ golang.org/x/sync v0.23.0 // indirect61+ golang.org/x/sys v0.48.0 // indirect5162 google.golang.org/protobuf v1.34.2 // indirect5263 modernc.org/libc v1.75.7 // indirect5364 modernc.org/mathutil v1.7.1 // indirect
M go.sum +46 -26
@@ -2,6 +2,12 @@ charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
22 charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=33 charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=44 charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=5+charm.land/log/v2 v2.0.1 h1:uMk+t1hM/fomIqt7vn/Ub0AKo7IHvnDu6NK17nbOVx0=6+charm.land/log/v2 v2.0.1/go.mod h1:VXZNB8zoPPEIcvd7bRi2fpQpGCWweCSfWOwczA9dibs=7+charm.land/ssh v0.4.3 h1:hr5cYmlUYsP+KxyG/ug7ZMzKKN9f6VOD9yPpcGqdzzM=8+charm.land/ssh v0.4.3/go.mod h1:so/3IECPNlYZSnE7JKn7NFmcUyyxJqIAeM4TJy35qPk=9+charm.land/wish/v2 v2.0.4 h1:GcXfPaag1GNp2FqL6A1/zR4s8ae6sPHBU0fxigE4PZI=10+charm.land/wish/v2 v2.0.4/go.mod h1:l09dLMs+e7kLT70z7ZPvcsrOBuqiURHUAnkRSs4cvdU=511 github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=612 github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=713 github.com/FelineStateMachine/typesafe-go v0.2.0 h1:7YFrdcsuvTvBf0dhOvifZVbM8d4StrckWJC3yEH3Ydk=
@@ -12,14 +18,20 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
1218 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=1319 github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=1420 github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=21+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=22+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=1523 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=1624 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=1725 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=1826 github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=19-github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=20-github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=27+github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=28+github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=29+github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 h1:tYBWVoMfQHTwp88mWeWH7o0uJFZqRJeEGJmeHpIR8Ms=30+github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=2131 github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=2232 github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=33+github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=34+github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=2335 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=2436 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=2537 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -28,18 +40,24 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
2840 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=2941 github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=3042 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=43+github.com/charmbracelet/x/xpty v0.1.4 h1:4jaW7u+8AHQMxesiVc+zUMsspu7GyDwtJO+gy/tFtW4=44+github.com/charmbracelet/x/xpty v0.1.4/go.mod h1:7t8P7BpPiolHJ1pLzz7/4ujDbD+sUxI9yA3CBOLOIcU=3145 github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=3246 github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=3347 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=3448 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=3549 github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=3650 github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=51+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=52+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=3753 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=3854 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=3955 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=4056 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=41-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=42-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=57+github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=58+github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=59+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=60+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=4361 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=4462 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=4563 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -58,8 +76,8 @@ github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBql
5876 github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=5977 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=6078 github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=61-github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=62-github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=79+github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=80+github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=6381 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=6482 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=6583 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -72,6 +90,8 @@ github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayM
7290 github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=7391 github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=7492 github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=93+github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=94+github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=7595 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=7696 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=7797 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -90,8 +110,8 @@ github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrI
90110 github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=91111 github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=92112 github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=93-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=94-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=113+github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=114+github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=95115 github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=96116 github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=97117 github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
@@ -100,26 +120,26 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
100120 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=101121 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=102122 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=103-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=104-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=105-golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=106-golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=123+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=124+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=125+golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=126+golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=107127 golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=108128 golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=109-golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=110-golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=111-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=112-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=113-golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=114-golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=115-golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=116-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=117-golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=118-golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=119-golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=120-golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=121-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=122-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=129+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=130+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=131+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=132+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=133+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=134+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=135+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=136+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=137+golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=138+golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=139+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=140+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=141+golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=142+golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=123143 google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=124144 google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=125145 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
A internal/confine/confine.go +134 -0
@@ -0,0 +1,134 @@
1+// Package confine maps the file names a user types to paths on disk,2+// keeping them inside one directory when 012 serves sessions over SSH.3+//4+// A Root's zero value confines nothing: names are used as typed,5+// relative to the working directory, as the local app always has. A Root6+// made with New resolves names inside its directory and rejects absolute7+// paths, names that climb out with "..", hidden files and directories,8+// and symbolic links that lead outside or nowhere.9+package confine10+11+import (12+ "errors"13+ "fmt"14+ "io/fs"15+ "os"16+ "path/filepath"17+ "slices"18+ "strings"19+)20+21+// ErrOutside is wrapped by every name a confined Root refuses.22+var ErrOutside = errors.New("outside the served directory")23+24+// Root is a directory names are confined to; the zero value confines25+// nothing.26+type Root struct {27+ dir string // absolute, with symbolic links resolved; "" confines nothing28+}29+30+// New confines names to dir, which must exist.31+func New(dir string) (Root, error) {32+ abs, err := filepath.Abs(dir)33+ if err != nil {34+ return Root{}, err35+ }36+ real, err := filepath.EvalSymlinks(abs)37+ if err != nil {38+ return Root{}, err39+ }40+ st, err := os.Stat(real)41+ if err != nil {42+ return Root{}, err43+ }44+ if !st.IsDir() {45+ return Root{}, fmt.Errorf("%s is not a directory", dir)46+ }47+ return Root{dir: real}, nil48+}49+50+// Confined reports whether r keeps names inside a directory.51+func (r Root) Confined() bool { return r.dir != "" }52+53+// Dir is the directory names are confined to, "" when unconfined.54+func (r Root) Dir() string { return r.dir }55+56+// Resolve returns the path to use on disk for name, as the user typed57+// it. Unconfined, that's name itself. Confined, it's a path inside the58+// root with every existing symbolic link along it followed, or an error59+// wrapping ErrOutside.60+func (r Root) Resolve(name string) (string, error) {61+ if r.dir == "" {62+ return name, nil63+ }64+ if name == "" {65+ return "", errors.New("no file name")66+ }67+ if filepath.IsAbs(name) || strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {68+ return "", refuse("absolute path")69+ }70+ clean := filepath.Clean(name)71+ if !filepath.IsLocal(clean) {72+ return "", ErrOutside73+ }74+ parts := strings.Split(clean, string(filepath.Separator))75+ if clean == "." {76+ parts = nil77+ }78+ if slices.ContainsFunc(parts, hidden) {79+ return "", refuse("hidden file")80+ }81+ cur := r.dir82+ for i, p := range parts {83+ next := filepath.Join(cur, p)84+ st, err := os.Lstat(next)85+ if errors.Is(err, fs.ErrNotExist) {86+ // The rest doesn't exist yet (a file about to be saved):87+ // nothing along it can lead elsewhere.88+ return filepath.Join(append([]string{cur}, parts[i:]...)...), nil89+ }90+ if err != nil {91+ return "", err92+ }93+ if st.Mode()&fs.ModeSymlink != 0 {94+ target, err := filepath.EvalSymlinks(next)95+ if err != nil {96+ return "", refuse("broken link")97+ }98+ if !r.contains(target) {99+ return "", refuse("link")100+ }101+ next = target102+ }103+ cur = next104+ }105+ return cur, nil106+}107+108+// Scrub removes the root's location from msg, such as an error naming a109+// resolved path, so it names files relative to the root.110+func (r Root) Scrub(msg string) string {111+ if r.dir == "" {112+ return msg113+ }114+ msg = strings.ReplaceAll(msg, r.dir+string(filepath.Separator), "")115+ return strings.ReplaceAll(msg, r.dir, ".")116+}117+118+// contains reports whether the resolved path p is the root or a visible119+// file or directory inside it.120+func (r Root) contains(p string) bool {121+ rel, err := filepath.Rel(r.dir, p)122+ if err != nil || !filepath.IsLocal(rel) {123+ return false124+ }125+ return rel == "." || !slices.ContainsFunc(strings.Split(rel, string(filepath.Separator)), hidden)126+}127+128+func hidden(part string) bool { return strings.HasPrefix(part, ".") }129+130+// refuse is ErrOutside with the reason. The name is left out, as callers131+// show it already.132+func refuse(why string) error {133+ return fmt.Errorf("%w (%s)", ErrOutside, why)134+}
A internal/confine/confine_test.go +144 -0
@@ -0,0 +1,144 @@
1+package confine2+3+import (4+ "errors"5+ "os"6+ "path/filepath"7+ "testing"8+)9+10+// served builds a served directory next to a private one:11+//12+// served/13+// a.012, sub/b.csv, .env, .hidden/c.01214+// in -> a.012 (link inside)15+// subln -> sub (link to a directory inside)16+// out -> ../private/secret.01217+// outdir -> ../private18+// abs -> /etc/passwd style absolute link to private19+// dangling -> ../private/nothing20+// toenv -> .env (link to a hidden file inside)21+// private/secret.01222+func served(t *testing.T) (Root, string) {23+ t.Helper()24+ top := t.TempDir()25+ dir, priv := filepath.Join(top, "served"), filepath.Join(top, "private")26+ for _, d := range []string{dir, priv, filepath.Join(dir, "sub"), filepath.Join(dir, ".hidden")} {27+ if err := os.MkdirAll(d, 0o755); err != nil {28+ t.Fatal(err)29+ }30+ }31+ for _, f := range []string{"served/a.012", "served/sub/b.csv", "served/.env", "served/.hidden/c.012", "private/secret.012"} {32+ if err := os.WriteFile(filepath.Join(top, f), []byte("x"), 0o644); err != nil {33+ t.Fatal(err)34+ }35+ }36+ links := map[string]string{37+ "in": "a.012",38+ "subln": "sub",39+ "out": "../private/secret.012",40+ "outdir": "../private",41+ "abs": filepath.Join(priv, "secret.012"),42+ "dangling": "../private/nothing",43+ "toenv": ".env",44+ "loop": "loop",45+ }46+ for name, target := range links {47+ if err := os.Symlink(target, filepath.Join(dir, name)); err != nil {48+ t.Fatal(err)49+ }50+ }51+ r, err := New(dir)52+ if err != nil {53+ t.Fatal(err)54+ }55+ return r, r.Dir()56+}57+58+func TestResolve(t *testing.T) {59+ r, dir := served(t)60+ ok := []struct{ name, want string }{61+ {"a.012", "a.012"},62+ {"./a.012", "a.012"},63+ {"sub/b.csv", "sub/b.csv"},64+ {"sub/../a.012", "a.012"},65+ {"new.012", "new.012"},66+ {"sub/new.csv", "sub/new.csv"},67+ {"missing/dir/new.012", "missing/dir/new.012"},68+ {"in", "a.012"},69+ {"subln/b.csv", "sub/b.csv"},70+ {".", "."},71+ {"a b.012", "a b.012"},72+ }73+ for _, c := range ok {74+ got, err := r.Resolve(c.name)75+ want := filepath.Join(dir, c.want)76+ if err != nil || got != want {77+ t.Errorf("Resolve(%q) = %q, %v; want %q", c.name, got, err, want)78+ }79+ }80+ escapes := []string{81+ "/etc/passwd",82+ filepath.Join(dir, "a.012"), // absolute, even inside83+ "..",84+ "../private/secret.012",85+ "sub/../../private/secret.012",86+ "sub/../..",87+ "./../served/a.012",88+ ".env",89+ ".hidden/c.012",90+ "sub/.x.csv",91+ "new/.env",92+ "out",93+ "outdir/secret.012",94+ "abs",95+ "dangling",96+ "toenv",97+ "loop",98+ `\\server\share`,99+ }100+ for _, name := range escapes {101+ got, err := r.Resolve(name)102+ if !errors.Is(err, ErrOutside) {103+ t.Errorf("Resolve(%q) = %q, %v; want ErrOutside", name, got, err)104+ }105+ }106+ if _, err := r.Resolve(""); err == nil {107+ t.Error("Resolve(\"\") succeeded")108+ }109+}110+111+func TestUnconfined(t *testing.T) {112+ var r Root113+ for _, name := range []string{"/etc/passwd", "../x", ".env", "a.012"} {114+ if got, err := r.Resolve(name); err != nil || got != name {115+ t.Errorf("Resolve(%q) = %q, %v; want it unchanged", name, got, err)116+ }117+ }118+ if r.Confined() {119+ t.Error("the zero Root is confined")120+ }121+}122+123+func TestNew(t *testing.T) {124+ if _, err := New(filepath.Join(t.TempDir(), "missing")); err == nil {125+ t.Error("New of a missing directory succeeded")126+ }127+ f := filepath.Join(t.TempDir(), "file")128+ os.WriteFile(f, nil, 0o644)129+ if _, err := New(f); err == nil {130+ t.Error("New of a file succeeded")131+ }132+ // A root reached through a link is resolved, so paths under it compare.133+ top := t.TempDir()134+ os.Mkdir(filepath.Join(top, "real"), 0o755)135+ os.Symlink("real", filepath.Join(top, "link"))136+ r, err := New(filepath.Join(top, "link"))137+ if err != nil {138+ t.Fatal(err)139+ }140+ real, _ := filepath.EvalSymlinks(filepath.Join(top, "real"))141+ if r.Dir() != real {142+ t.Errorf("Dir = %q, want %q", r.Dir(), real)143+ }144+}
A internal/serve/keys.go +155 -0
@@ -0,0 +1,155 @@
1+package serve2+3+import (4+ "bufio"5+ "bytes"6+ "crypto/ed25519"7+ "crypto/rand"8+ "encoding/pem"9+ "errors"10+ "fmt"11+ "io/fs"12+ "os"13+ "path/filepath"14+ "strings"15+16+ gossh "golang.org/x/crypto/ssh"17+)18+19+// hostKey loads the server's private key from path, generating an20+// ed25519 key there first if there's none. A key others can read or21+// write is refused, as sshd refuses one.22+func hostKey(path string) (gossh.Signer, error) {23+ st, err := os.Stat(path)24+ if errors.Is(err, fs.ErrNotExist) {25+ if err := generateHostKey(path); err != nil {26+ return nil, fmt.Errorf("generating the host key: %w", err)27+ }28+ st, err = os.Stat(path)29+ }30+ if err != nil {31+ return nil, err32+ }33+ if st.Mode().Perm()&0o077 != 0 {34+ return nil, fmt.Errorf("host key %s is accessible by others (%v); chmod 600 it", path, st.Mode().Perm())35+ }36+ data, err := os.ReadFile(path)37+ if err != nil {38+ return nil, err39+ }40+ s, err := gossh.ParsePrivateKey(data)41+ if err != nil {42+ return nil, fmt.Errorf("host key %s: %w", path, err)43+ }44+ return s, nil45+}46+47+// generateHostKey writes a new ed25519 key to path (0600) and its public48+// half beside it, creating the directory (0700) if needed.49+func generateHostKey(path string) error {50+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {51+ return err52+ }53+ pub, priv, err := ed25519.GenerateKey(rand.Reader)54+ if err != nil {55+ return err56+ }57+ block, err := gossh.MarshalPrivateKey(priv, "012 host key")58+ if err != nil {59+ return err60+ }61+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)62+ if err != nil {63+ return err64+ }65+ _, err = f.Write(pem.EncodeToMemory(block))66+ if cerr := f.Close(); err == nil {67+ err = cerr68+ }69+ if err != nil {70+ os.Remove(path)71+ return err72+ }73+ sshPub, err := gossh.NewPublicKey(pub)74+ if err != nil {75+ return err76+ }77+ return os.WriteFile(path+".pub", gossh.MarshalAuthorizedKey(sshPub), 0o644)78+}79+80+// safeOptions are the authorized_keys options a key may carry and still81+// be accepted: they only turn off things 012 never offers. Keys with any82+// other option (command=, from=, no-pty, expiry-time= and so on) are83+// skipped, since 012 can't honor the restriction.84+var safeOptions = map[string]bool{85+ "no-port-forwarding": true,86+ "no-agent-forwarding": true,87+ "no-x11-forwarding": true,88+ "no-user-rc": true,89+ "restrict": true,90+ "pty": true,91+}92+93+// authorizedKeys reads the keys allowed to log in from path, in94+// OpenSSH's format. It fails if the file can be written by others, as95+// sshd's StrictModes does, or if a line doesn't parse. skipped counts96+// keys left out for their options.97+func authorizedKeys(path string) (keys []gossh.PublicKey, skipped int, err error) {98+ st, err := os.Stat(path)99+ if err != nil {100+ return nil, 0, err101+ }102+ if !st.Mode().IsRegular() {103+ return nil, 0, fmt.Errorf("%s is not a regular file", path)104+ }105+ if st.Mode().Perm()&0o022 != 0 {106+ return nil, 0, fmt.Errorf("%s can be written by others (%v); chmod go-w it", path, st.Mode().Perm())107+ }108+ data, err := os.ReadFile(path)109+ if err != nil {110+ return nil, 0, err111+ }112+ sc := bufio.NewScanner(bytes.NewReader(data))113+ sc.Buffer(nil, 1<<20)114+ for n := 1; sc.Scan(); n++ {115+ line := bytes.TrimSpace(sc.Bytes())116+ if len(line) == 0 || line[0] == '#' {117+ continue118+ }119+ key, _, opts, _, err := gossh.ParseAuthorizedKey(line)120+ if err != nil {121+ return nil, 0, fmt.Errorf("%s:%d: %w", path, n, err)122+ }123+ if !acceptable(opts) {124+ skipped++125+ continue126+ }127+ keys = append(keys, key)128+ }129+ return keys, skipped, sc.Err()130+}131+132+// acceptable reports whether a key with these authorized_keys options133+// may log in to 012.134+func acceptable(opts []string) bool {135+ restricted, pty := false, false136+ for _, o := range opts {137+ o = strings.ToLower(o)138+ if !safeOptions[o] {139+ return false140+ }141+ restricted = restricted || o == "restrict"142+ pty = pty || o == "pty"143+ }144+ // restrict takes the terminal away unless pty gives it back, and 012145+ // needs one.146+ return !restricted || pty147+}148+149+// fingerprint is a key's SHA256 fingerprint, as ssh-keygen -l shows it.150+func fingerprint(k gossh.PublicKey) string {151+ if k == nil {152+ return ""153+ }154+ return gossh.FingerprintSHA256(k)155+}
A internal/serve/keys_test.go +126 -0
@@ -0,0 +1,126 @@
1+package serve2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ gossh "golang.org/x/crypto/ssh"10+)11+12+func TestAuthorizedKeys(t *testing.T) {13+ keys := []gossh.Signer{newKey(t), newKey(t), newKey(t), newKey(t), newKey(t), newKey(t)}14+ line := func(i int, opts string) string {15+ s := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(keys[i].PublicKey())))16+ if opts != "" {17+ s = opts + " " + s18+ }19+ return s + " user@host\n"20+ }21+ content := "# comment\n\n" +22+ line(0, "") +23+ line(1, `command="rrsync /backup"`) + // a restricted backup key: not for 01224+ line(2, `from="10.0.0.0/8"`) + // 012 can't check where it comes from25+ line(3, "restrict") + // no terminal26+ line(4, "restrict,pty") +27+ line(5, "no-port-forwarding,no-agent-forwarding")28+ path := filepath.Join(t.TempDir(), "authorized_keys")29+ os.WriteFile(path, []byte(content), 0o600)30+ got, skipped, err := authorizedKeys(path)31+ if err != nil {32+ t.Fatal(err)33+ }34+ want := []int{0, 4, 5}35+ if len(got) != len(want) || skipped != 3 {36+ t.Fatalf("got %d keys, skipped %d; want %d and 3", len(got), skipped, len(want))37+ }38+ for i, k := range want {39+ if fingerprint(got[i]) != fingerprint(keys[k].PublicKey()) {40+ t.Errorf("key %d is not key %d", i, k)41+ }42+ }43+44+ // Writable by others: refused, as sshd's StrictModes does.45+ os.Chmod(path, 0o620)46+ if _, _, err := authorizedKeys(path); err == nil || !strings.Contains(err.Error(), "written by others") {47+ t.Errorf("group-writable file: %v", err)48+ }49+ os.Chmod(path, 0o644)50+ if _, _, err := authorizedKeys(path); err != nil {51+ t.Errorf("0644 file: %v", err)52+ }53+54+ // A line that doesn't parse names its line.55+ os.WriteFile(path, []byte(line(0, "")+"ssh-ed25519 notbase64!\n"), 0o600)56+ if _, _, err := authorizedKeys(path); err == nil || !strings.Contains(err.Error(), ":2:") {57+ t.Errorf("bad line: %v", err)58+ }59+ if _, _, err := authorizedKeys(filepath.Join(t.TempDir(), "missing")); err == nil {60+ t.Error("missing file accepted")61+ }62+ if _, _, err := authorizedKeys(t.TempDir()); err == nil {63+ t.Error("a directory accepted")64+ }65+}66+67+func TestHostKey(t *testing.T) {68+ dir := filepath.Join(t.TempDir(), "config", "012")69+ path := filepath.Join(dir, "ssh_host_ed25519_key")70+ s1, err := hostKey(path)71+ if err != nil {72+ t.Fatal(err)73+ }74+ if s1.PublicKey().Type() != gossh.KeyAlgoED25519 {75+ t.Errorf("host key type %s", s1.PublicKey().Type())76+ }77+ for p, want := range map[string]os.FileMode{path: 0o600, dir: 0o700 | os.ModeDir, path + ".pub": 0o644} {78+ if st, err := os.Stat(p); err != nil || st.Mode() != want {79+ t.Errorf("%s: mode %v, %v; want %v", filepath.Base(p), st.Mode(), err, want)80+ }81+ }82+ // Loaded again, not regenerated.83+ s2, err := hostKey(path)84+ if err != nil || fingerprint(s2.PublicKey()) != fingerprint(s1.PublicKey()) {85+ t.Errorf("reloaded host key differs: %v", err)86+ }87+ os.Chmod(path, 0o644)88+ if _, err := hostKey(path); err == nil || !strings.Contains(err.Error(), "chmod 600") {89+ t.Errorf("readable host key: %v", err)90+ }91+}92+93+func TestNewChecks(t *testing.T) {94+ key := newKey(t)95+ top := t.TempDir()96+ ak := filepath.Join(top, "authorized_keys")97+ os.WriteFile(ak, gossh.MarshalAuthorizedKey(key.PublicKey()), 0o600)98+ empty := filepath.Join(top, "empty")99+ os.WriteFile(empty, []byte("# nobody\n"), 0o600)100+ base := Options{Dir: top, Listen: "127.0.0.1:0", AuthorizedKeys: ak, HostKey: filepath.Join(top, "hk"), MaxSessions: 1}101+ cases := map[string]func(*Options){102+ "missing dir": func(o *Options) { o.Dir = filepath.Join(top, "nope") },103+ "no keys": func(o *Options) { o.AuthorizedKeys = empty },104+ "missing keys": func(o *Options) { o.AuthorizedKeys = filepath.Join(top, "nope") },105+ "no listen": func(o *Options) { o.Listen = "" },106+ "no sessions": func(o *Options) { o.MaxSessions = 0 },107+ "negative idle": func(o *Options) { o.IdleTimeout = -1 },108+ "no host key": func(o *Options) { o.HostKey = "" },109+ "no authorized keys": func(o *Options) { o.AuthorizedKeys = "" },110+ }111+ for name, change := range cases {112+ o := base113+ change(&o)114+ if _, err := New(o, Deps{}); err == nil {115+ t.Errorf("%s: New succeeded", name)116+ }117+ }118+ if _, err := New(base, Deps{}); err != nil {119+ t.Errorf("valid options: %v", err)120+ }121+ d := Defaults()122+ if d.Listen != "127.0.0.1:2312" || !strings.HasSuffix(d.AuthorizedKeys, filepath.Join(".ssh", "authorized_keys")) ||123+ !strings.HasSuffix(d.HostKey, filepath.Join("012", "ssh_host_ed25519_key")) || d.IdleTimeout <= 0 || d.MaxSessions <= 0 {124+ t.Errorf("Defaults() = %+v", d)125+ }126+}
A internal/serve/options.go +75 -0
@@ -0,0 +1,75 @@
1+// Package serve runs 012 as an SSH server: each session gets its own2+// spreadsheet over its own terminal, confined to one directory.3+//4+// It is built for one person reaching their own files from elsewhere,5+// not for sharing: public-key auth against an authorized_keys file only,6+// on the loopback address unless told otherwise, with no port7+// forwarding, no commands and no subsystems. See docs/ssh.md.8+package serve9+10+import (11+ "errors"12+ "fmt"13+ "os"14+ "path/filepath"15+ "time"16+)17+18+// Options are 012 serve's settings, one field per flag. Defaults gives19+// their default values; cmd/012 parses flags into them.20+type Options struct {21+ // Dir is the served directory: sessions open, save, import and22+ // download only inside it.23+ Dir string24+ // Listen is the TCP address to listen on.25+ Listen string26+ // AuthorizedKeys is the file of public keys allowed to connect, in27+ // OpenSSH's authorized_keys format. It is read again on every login.28+ AuthorizedKeys string29+ // HostKey is the server's private key file, an ed25519 key generated30+ // there (0600, in a 0700 directory) when missing.31+ HostKey string32+ // IdleTimeout ends a session that has had no input for this long; 033+ // never does.34+ IdleTimeout time.Duration35+ // MaxSessions is how many sessions may run at once; more are turned36+ // away.37+ MaxSessions int38+}39+40+// Default values.41+const (42+ DefaultListen = "127.0.0.1:2312"43+ DefaultIdleTimeout = 30 * time.Minute44+ DefaultMaxSessions = 845+)46+47+// Defaults returns the default options: the current directory, served on48+// the loopback address, keys from ~/.ssh/authorized_keys and the host key49+// in 012's config directory.50+func Defaults() Options {51+ o := Options{Dir: ".", Listen: DefaultListen, IdleTimeout: DefaultIdleTimeout, MaxSessions: DefaultMaxSessions}52+ if home, err := os.UserHomeDir(); err == nil {53+ o.AuthorizedKeys = filepath.Join(home, ".ssh", "authorized_keys")54+ }55+ if cfg, err := os.UserConfigDir(); err == nil {56+ o.HostKey = filepath.Join(cfg, "012", "ssh_host_ed25519_key")57+ }58+ return o59+}60+61+func (o Options) validate() error {62+ switch {63+ case o.Listen == "":64+ return errors.New("no address to listen on")65+ case o.AuthorizedKeys == "":66+ return errors.New("no authorized_keys file")67+ case o.HostKey == "":68+ return errors.New("no host key file")69+ case o.MaxSessions < 1:70+ return fmt.Errorf("max sessions is %d, it must be at least 1", o.MaxSessions)71+ case o.IdleTimeout < 0:72+ return errors.New("the idle timeout can't be negative")73+ }74+ return nil75+}
A internal/serve/serve.go +296 -0
@@ -0,0 +1,296 @@
1+package serve2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "io"8+ "log/slog"9+ "net"10+ "sync/atomic"11+ "time"12+13+ tea "charm.land/bubbletea/v2"14+ "charm.land/ssh"15+ bm "charm.land/wish/v2/bubbletea"16+ gossh "golang.org/x/crypto/ssh"17+18+ "github.com/FelineStateMachine/012/internal/confine"19+ "github.com/FelineStateMachine/012/internal/jev"20+ "github.com/FelineStateMachine/012/internal/sheet"21+ "github.com/FelineStateMachine/012/internal/telemetry"22+ "github.com/FelineStateMachine/012/internal/ui"23+)24+25+// handshakeTimeout bounds the time from connecting to logging in.26+const handshakeTimeout = 30 * time.Second27+28+// Server serves 012 sessions over SSH.29+type Server struct {30+ opts Options31+ root confine.Root32+ log *slog.Logger33+ jev jev.Client // nil: JEV functions are off34+ ssh *ssh.Server35+ hostPub gossh.PublicKey36+ slots chan struct{} // one per running session37+ active atomic.Int6438+}39+40+// Deps are what a Server takes from its surroundings rather than its41+// options.42+type Deps struct {43+ // Log gets a line per login, rejection and session, besides the44+ // telemetry events; nil discards them.45+ Log *slog.Logger46+ // JEV answers JEV functions, with a cache per session; nil turns them47+ // off.48+ JEV jev.Client49+}50+51+// New checks o and prepares a server: the served directory, the host52+// key (generated if missing) and the authorized keys must be usable.53+func New(o Options, d Deps) (*Server, error) {54+ if err := o.validate(); err != nil {55+ return nil, err56+ }57+ root, err := confine.New(o.Dir)58+ if err != nil {59+ return nil, fmt.Errorf("served directory: %w", err)60+ }61+ keys, _, err := authorizedKeys(o.AuthorizedKeys)62+ if err != nil {63+ return nil, fmt.Errorf("authorized keys: %w", err)64+ }65+ if len(keys) == 0 {66+ return nil, fmt.Errorf("authorized keys: no usable keys in %s", o.AuthorizedKeys)67+ }68+ signer, err := hostKey(o.HostKey)69+ if err != nil {70+ return nil, err71+ }72+ if d.Log == nil {73+ d.Log = slog.New(slog.DiscardHandler)74+ }75+ s := &Server{opts: o, root: root, log: d.Log, jev: d.JEV, hostPub: signer.PublicKey(), slots: make(chan struct{}, o.MaxSessions)}76+ s.ssh = &ssh.Server{77+ Addr: o.Listen,78+ Handler: s.session,79+ HostSigners: []ssh.Signer{signer},80+ Version: "012",81+ PublicKeyHandler: s.authorize,82+ // No password or keyboard-interactive auth, no forwarding of any83+ // kind, no global requests and no subsystems: only sessions.84+ ChannelHandlers: map[string]ssh.ChannelHandler{"session": ssh.DefaultSessionHandler},85+ RequestHandlers: map[string]ssh.RequestHandler{},86+ SubsystemHandlers: map[string]ssh.SubsystemHandler{},87+ SessionRequestCallback: allowShell,88+ ConnectionFailedCallback: func(c net.Conn, err error) {89+ s.event("ssh.failed", slog.String("remote", c.RemoteAddr().String()), slog.String("error", err.Error()))90+ },91+ HandshakeTimeout: handshakeTimeout,92+ }93+ if o.IdleTimeout > 0 {94+ // A backstop for connections without a session. Sessions end95+ // on their own after IdleTimeout without input (see run), which96+ // keepalives don't reset.97+ s.ssh.IdleTimeout = o.IdleTimeout + time.Minute98+ }99+ return s, nil100+}101+102+// allowShell lets a session start 012 and nothing else: exec requests103+// (ssh host command) and subsystems (sftp) are refused.104+func allowShell(sess ssh.Session, requestType string) bool {105+ return requestType == "shell"106+}107+108+// HostKey is the server's public key, for known_hosts.109+func (s *Server) HostKey() gossh.PublicKey { return s.hostPub }110+111+// Dir is the served directory, with links resolved.112+func (s *Server) Dir() string { return s.root.Dir() }113+114+// Sessions is how many sessions are running.115+func (s *Server) Sessions() int { return int(s.active.Load()) }116+117+// ListenAndServe listens on the options' address and serves until118+// Shutdown or Close.119+func (s *Server) ListenAndServe() error {120+ l, err := net.Listen("tcp", s.opts.Listen)121+ if err != nil {122+ return err123+ }124+ return s.Serve(l)125+}126+127+// Serve serves sessions arriving on l.128+func (s *Server) Serve(l net.Listener) error {129+ s.log.Info("serving", "addr", l.Addr().String(), "dir", s.root.Dir(), "host_key", fingerprint(s.hostPub))130+ err := s.ssh.Serve(l)131+ if errors.Is(err, ssh.ErrServerClosed) {132+ return nil133+ }134+ return err135+}136+137+// Shutdown stops listening and waits for sessions to end, until ctx is138+// done.139+func (s *Server) Shutdown(ctx context.Context) error { return s.ssh.Shutdown(ctx) }140+141+// Close stops the server and drops every connection at once.142+func (s *Server) Close() error { return s.ssh.Close() }143+144+// authorize accepts key if the authorized_keys file lists it, reading145+// the file again so edits apply without a restart.146+func (s *Server) authorize(ctx ssh.Context, key ssh.PublicKey) bool {147+ keys, _, err := authorizedKeys(s.opts.AuthorizedKeys)148+ if err != nil {149+ s.event("ssh.auth_error", slog.String("error", err.Error()))150+ return false151+ }152+ for _, k := range keys {153+ if ssh.KeysEqual(key, k) {154+ return true155+ }156+ }157+ s.event("ssh.rejected", slog.String("user", ctx.User()), slog.String("key", fingerprint(key)),158+ slog.String("remote", ctx.RemoteAddr().String()))159+ return false160+}161+162+// event logs name to telemetry and the server log. Events name users,163+// keys and addresses, never what's in files.164+func (s *Server) event(name string, attrs ...slog.Attr) {165+ telemetry.Event(name, 0, attrs...)166+ s.log.LogAttrs(context.Background(), slog.LevelInfo, name, attrs...)167+}168+169+// session runs one 012 over one SSH session.170+func (s *Server) session(sess ssh.Session) {171+ who := []slog.Attr{slog.String("user", sess.User()), slog.String("key", fingerprint(sess.PublicKey())),172+ slog.String("remote", sess.RemoteAddr().String())}173+ pty, winch, ok := sess.Pty()174+ if !ok {175+ fmt.Fprint(sess.Stderr(), "012 needs a terminal: connect with ssh -t\r\n")176+ sess.Exit(1)177+ return178+ }179+ select {180+ case s.slots <- struct{}{}:181+ default:182+ s.event("ssh.full", who...)183+ fmt.Fprintf(sess.Stderr(), "012: %d sessions are running, the most this server allows; try again later\r\n", s.opts.MaxSessions)184+ sess.Exit(1)185+ return186+ }187+ n := s.active.Add(1)188+ telemetry.Set("ssh_sessions", n)189+ s.event("ssh.session", append(who, slog.String("term", pty.Term), slog.Int("width", pty.Window.Width), slog.Int("height", pty.Window.Height))...)190+ start := time.Now()191+ defer func() {192+ telemetry.Set("ssh_sessions", s.active.Add(-1))193+ <-s.slots194+ }()195+196+ idle := s.run(sess, pty, winch)197+ d := time.Since(start)198+ s.event("ssh.session_end", append(who, slog.Duration("duration", d), slog.Bool("idle", idle))...)199+ if idle {200+ fmt.Fprintf(sess, "012: closed after %v without input\r\n", s.opts.IdleTimeout)201+ }202+ sess.Exit(0)203+}204+205+// run runs the program until it quits, the client goes away or the206+// session goes idle, which it reports.207+func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window) (idle bool) {208+ m := ui.New(sheet.New(), "")209+ env := append(sess.Environ(), "TERM="+pty.Term)210+ m.Serve(s.root, env)211+ if s.jev != nil {212+ // Each session has its own cache: it queues that session's213+ // questions, and its answers recalculate that session's sheets.214+ m.EnableJEV(s.jev, jev.NewCache())215+ }216+ in := newActivity(sess)217+ p := tea.NewProgram(m, append(bm.MakeOptions(sess), tea.WithInput(in))...)218+219+ // The connection's context ends when the client goes away; cancel220+ // stops the goroutines below when the program quits first.221+ ctx, cancel := context.WithCancel(sess.Context())222+ defer cancel()223+ go resize(ctx, p, winch)224+ var idled atomic.Bool225+ if s.opts.IdleTimeout > 0 {226+ go func() {227+ if in.waitIdle(ctx, s.opts.IdleTimeout) {228+ idled.Store(true)229+ p.Quit()230+ }231+ }()232+ }233+ if _, err := p.Run(); err != nil && !errors.Is(err, tea.ErrProgramKilled) {234+ s.log.Error("session", "error", err.Error())235+ }236+ p.Kill()237+ return idled.Load()238+}239+240+// resize tells p about the client's window changes, and quits it when241+// ctx ends.242+func resize(ctx context.Context, p *tea.Program, winch <-chan ssh.Window) {243+ for {244+ select {245+ case <-ctx.Done():246+ p.Quit()247+ return248+ case w, ok := <-winch:249+ if !ok {250+ <-ctx.Done()251+ p.Quit()252+ return253+ }254+ p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})255+ }256+ }257+}258+259+// activity is a session's input, noting when it last had any.260+type activity struct {261+ r io.Reader262+ last atomic.Int64 // UnixNano of the last read that returned bytes263+}264+265+func newActivity(r io.Reader) *activity {266+ a := &activity{r: r}267+ a.last.Store(time.Now().UnixNano())268+ return a269+}270+271+func (a *activity) Read(p []byte) (int, error) {272+ n, err := a.r.Read(p)273+ if n > 0 {274+ a.last.Store(time.Now().UnixNano())275+ }276+ return n, err277+}278+279+// waitIdle waits until there's been no input for d, reporting true, or280+// until ctx ends, reporting false.281+func (a *activity) waitIdle(ctx context.Context, d time.Duration) bool {282+ t := time.NewTimer(d)283+ defer t.Stop()284+ for {285+ select {286+ case <-ctx.Done():287+ return false288+ case now := <-t.C:289+ left := d - now.Sub(time.Unix(0, a.last.Load()))290+ if left <= 0 {291+ return true292+ }293+ t.Reset(left)294+ }295+ }296+}
A internal/serve/serve_test.go +360 -0
@@ -0,0 +1,360 @@
1+package serve2+3+import (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"19+20+ typesafe "github.com/FelineStateMachine/typesafe-go"21+ gossh "golang.org/x/crypto/ssh"22+23+ "github.com/FelineStateMachine/012/internal/sheet"24+)25+26+// newKey makes a client key.27+func newKey(t testing.TB) gossh.Signer {28+ t.Helper()29+ _, priv, err := ed25519.GenerateKey(rand.Reader)30+ if err != nil {31+ t.Fatal(err)32+ }33+ s, err := gossh.NewSignerFromKey(priv)34+ if err != nil {35+ t.Fatal(err)36+ }37+ return s38+}39+40+// testServer serves a fresh directory on a random loopback port to the41+// given keys, returning the server, its address and the served42+// directory.43+func testServer(t testing.TB, change func(*Options), keys ...gossh.Signer) (*Server, string, string) {44+ t.Helper()45+ top := t.TempDir()46+ dir := filepath.Join(top, "served")47+ os.Mkdir(dir, 0o755)48+ var ak bytes.Buffer49+ for _, k := range keys {50+ ak.Write(gossh.MarshalAuthorizedKey(k.PublicKey()))51+ }52+ akPath := filepath.Join(top, "authorized_keys")53+ if err := os.WriteFile(akPath, ak.Bytes(), 0o600); err != nil {54+ t.Fatal(err)55+ }56+ o := Defaults()57+ o.Dir, o.AuthorizedKeys, o.HostKey, o.Listen = dir, akPath, filepath.Join(top, "config", "host_key"), "127.0.0.1:0"58+ if change != nil {59+ change(&o)60+ }61+ srv, err := New(o, Deps{})62+ if err != nil {63+ t.Fatal(err)64+ }65+ l, err := net.Listen("tcp", o.Listen)66+ if err != nil {67+ t.Fatal(err)68+ }69+ done := make(chan error, 1)70+ go func() { done <- srv.Serve(l) }()71+ t.Cleanup(func() {72+ srv.Close()73+ if err := <-done; err != nil {74+ t.Errorf("Serve: %v", err)75+ }76+ })77+ return srv, l.Addr().String(), srv.Dir()78+}79+80+func dial(addr string, key gossh.Signer, host gossh.PublicKey) (*gossh.Client, error) {81+ return gossh.Dial("tcp", addr, &gossh.ClientConfig{82+ User: "dami",83+ Auth: []gossh.AuthMethod{gossh.PublicKeys(key)},84+ HostKeyCallback: gossh.FixedHostKey(host),85+ Timeout: 5 * time.Second,86+ })87+}88+89+// term is a client session with a terminal, collecting what it's sent.90+type term struct {91+ sess *gossh.Session92+ stdin io.WriteCloser93+ mu sync.Mutex94+ out bytes.Buffer95+ done chan error96+ wrote chan struct{} // signalled after output arrives97+}98+99+func openTerm(t testing.TB, c *gossh.Client, w, h int) *term {100+ t.Helper()101+ sess, err := c.NewSession()102+ if err != nil {103+ t.Fatal(err)104+ }105+ if err := sess.RequestPty("xterm-256color", h, w, gossh.TerminalModes{}); err != nil {106+ t.Fatal(err)107+ }108+ tm := &term{sess: sess, done: make(chan error, 1), wrote: make(chan struct{}, 1)}109+ tm.stdin, _ = sess.StdinPipe()110+ sess.Stdout = tm111+ if err := sess.Shell(); err != nil {112+ t.Fatal(err)113+ }114+ go func() { tm.done <- sess.Wait() }()115+ return tm116+}117+118+func (tm *term) Write(p []byte) (int, error) {119+ tm.mu.Lock()120+ n, err := tm.out.Write(p)121+ tm.mu.Unlock()122+ select {123+ case tm.wrote <- struct{}{}:124+ default:125+ }126+ return n, err127+}128+129+func (tm *term) output() string {130+ tm.mu.Lock()131+ defer tm.mu.Unlock()132+ return tm.out.String()133+}134+135+// waitFor waits until the output holds each of want.136+func (tm *term) waitFor(t testing.TB, want ...string) {137+ t.Helper()138+ deadline := time.Now().Add(10 * time.Second)139+ for time.Now().Before(deadline) {140+ out := tm.output()141+ ok := true142+ for _, w := range want {143+ ok = ok && strings.Contains(out, w)144+ }145+ if ok {146+ return147+ }148+ time.Sleep(10 * time.Millisecond)149+ }150+ t.Fatalf("output never showed %q; got %q", want, tm.output())151+}152+153+// waitClosed waits for the session to end.154+func (tm *term) waitClosed(t testing.TB) {155+ t.Helper()156+ select {157+ case <-tm.done:158+ case <-time.After(10 * time.Second):159+ t.Fatal("the session didn't close")160+ }161+}162+163+// A session gets its own 012: the first screen renders at the client's164+// size, and quitting ends the session.165+func TestSession(t *testing.T) {166+ key := newKey(t)167+ srv, addr, dir := testServer(t, nil, key)168+ c, err := dial(addr, key, srv.HostKey())169+ if err != nil {170+ t.Fatal(err)171+ }172+ defer c.Close()173+ tm := openTerm(t, c, 90, 20)174+ tm.waitFor(t, "READY", "012 - untitled", "Sheet1")175+ if n := srv.Sessions(); n != 1 {176+ t.Errorf("Sessions() = %d", n)177+ }178+179+ // Type a number and save it: the file lands in the served directory.180+ io.WriteString(tm.stdin, "42\r")181+ // Copying goes to the client's clipboard, through its terminal182+ // (OSC 52), not the server's.183+ io.WriteString(tm.stdin, "\x1b[A\x03")184+ tm.waitFor(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte("42")))185+ io.WriteString(tm.stdin, "\x13") // Ctrl+S asks for a name186+ tm.waitFor(t, "Save as")187+ io.WriteString(tm.stdin, "kept\r")188+ waitFile(t, filepath.Join(dir, "kept.012"))189+190+ io.WriteString(tm.stdin, "\x11") // Ctrl+Q: saved, so it quits at once191+ tm.waitClosed(t)192+ deadline := time.Now().Add(5 * time.Second)193+ for srv.Sessions() != 0 && time.Now().Before(deadline) {194+ time.Sleep(10 * time.Millisecond)195+ }196+ if n := srv.Sessions(); n != 0 {197+ t.Errorf("Sessions() = %d after quitting", n)198+ }199+}200+201+func waitFile(t *testing.T, path string) {202+ t.Helper()203+ deadline := time.Now().Add(5 * time.Second)204+ for time.Now().Before(deadline) {205+ if f, err := os.Open(path); err == nil {206+ s, err := sheet.Read(f)207+ f.Close()208+ if err == nil && s.Cell(sheet.Addr{}) != nil && s.Cell(sheet.Addr{}).Input == "42" {209+ return210+ }211+ }212+ time.Sleep(10 * time.Millisecond)213+ }214+ t.Fatalf("%s wasn't saved", path)215+}216+217+// A key missing from authorized_keys can't log in, and neither can a218+// password.219+func TestRejected(t *testing.T) {220+ good, bad := newKey(t), newKey(t)221+ srv, addr, _ := testServer(t, nil, good)222+ if c, err := dial(addr, bad, srv.HostKey()); err == nil {223+ c.Close()224+ t.Fatal("a key not in authorized_keys logged in")225+ }226+ c, err := gossh.Dial("tcp", addr, &gossh.ClientConfig{227+ User: "dami",228+ Auth: []gossh.AuthMethod{gossh.Password("hunter2"), gossh.KeyboardInteractive(229+ func(string, string, []string, []bool) ([]string, error) { return nil, nil })},230+ HostKeyCallback: gossh.FixedHostKey(srv.HostKey()),231+ Timeout: 5 * time.Second,232+ })233+ if err == nil {234+ c.Close()235+ t.Fatal("password auth logged in")236+ }237+}238+239+// Logged in, a client can run 012 and nothing else: no commands, no240+// subsystems, no forwarding, no session without a terminal.241+func TestOnlyShell(t *testing.T) {242+ key := newKey(t)243+ srv, addr, _ := testServer(t, nil, key)244+ c, err := dial(addr, key, srv.HostKey())245+ if err != nil {246+ t.Fatal(err)247+ }248+ defer c.Close()249+250+ sess, _ := c.NewSession()251+ if out, err := sess.CombinedOutput("cat /etc/passwd"); err == nil || len(out) > 0 {252+ t.Errorf("exec ran: %q, %v", out, err)253+ }254+ sess, _ = c.NewSession()255+ if err := sess.RequestSubsystem("sftp"); err == nil {256+ t.Error("the sftp subsystem started")257+ }258+ if conn, err := c.Dial("tcp", "127.0.0.1:22"); err == nil {259+ conn.Close()260+ t.Error("local port forwarding was allowed")261+ }262+ if l, err := c.Listen("tcp", "127.0.0.1:0"); err == nil {263+ l.Close()264+ t.Error("remote port forwarding was allowed")265+ }266+ // A shell without a terminal is told to ask for one and closed.267+ sess, _ = c.NewSession()268+ var stderr bytes.Buffer269+ sess.Stderr = &stderr270+ sess.Stdout = io.Discard271+ if err := sess.Shell(); err != nil {272+ t.Fatal(err)273+ }274+ var exit *gossh.ExitError275+ if err := sess.Wait(); !errors.As(err, &exit) || exit.ExitStatus() != 1 || !strings.Contains(stderr.String(), "needs a terminal") {276+ t.Errorf("shell without a terminal: %v, %q", err, stderr.String())277+ }278+}279+280+// Sessions past the limit are turned away; a place frees when one ends.281+func TestMaxSessions(t *testing.T) {282+ key := newKey(t)283+ srv, addr, _ := testServer(t, func(o *Options) { o.MaxSessions = 1 }, key)284+ c, err := dial(addr, key, srv.HostKey())285+ if err != nil {286+ t.Fatal(err)287+ }288+ defer c.Close()289+ first := openTerm(t, c, 80, 24)290+ first.waitFor(t, "READY")291+ second := openTerm(t, c, 80, 24)292+ second.waitClosed(t)293+ io.WriteString(first.stdin, "\x11")294+ first.waitClosed(t)295+ third := openTerm(t, c, 80, 24)296+ third.waitFor(t, "READY")297+}298+299+// A session without input for the idle timeout is closed with a note.300+func TestIdleTimeout(t *testing.T) {301+ key := newKey(t)302+ srv, addr, _ := testServer(t, func(o *Options) { o.IdleTimeout = 300 * time.Millisecond }, key)303+ c, err := dial(addr, key, srv.HostKey())304+ if err != nil {305+ t.Fatal(err)306+ }307+ defer c.Close()308+ tm := openTerm(t, c, 80, 24)309+ tm.waitFor(t, "READY")310+ tm.waitClosed(t)311+ if !strings.Contains(tm.output(), "closed after 300ms without input") {312+ t.Errorf("no idle note in %q", tm.output()[max(0, len(tm.output())-200):])313+ }314+}315+316+// fakeJEV answers every question with 0.9 (90%), slowly enough317+// that two sessions' questions are in flight together.318+type fakeJEV struct{ calls atomic.Int64 }319+320+func (f *fakeJEV) SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {321+ f.calls.Add(1)322+ time.Sleep(50 * time.Millisecond)323+ return &typesafe.SystemOneResponse{Answers: map[string]typesafe.Answer{"answer": typesafe.NoulAnswer{Noul: 0.9}}}, nil324+}325+326+// Two sessions are two separate spreadsheets: what one types, the other327+// doesn't see, and each gets the answers to its own JEV questions.328+func TestSessionsAreSeparate(t *testing.T) {329+ key := newKey(t)330+ fake := &fakeJEV{}331+ srv, addr, _ := testServer(t, nil, key)332+ srv.jev = fake333+ c, err := dial(addr, key, srv.HostKey())334+ if err != nil {335+ t.Fatal(err)336+ }337+ defer c.Close()338+ a, b := openTerm(t, c, 80, 24), openTerm(t, c, 100, 30)339+ a.waitFor(t, "READY")340+ b.waitFor(t, "READY")341+ io.WriteString(a.stdin, "only in a\r")342+ a.waitFor(t, "only in a")343+ for _, tm := range []*term{a, b} {344+ io.WriteString(tm.stdin, `=JEV.PROB("the box was crushed", "Is this a complaint?")`+"\r")345+ }346+ a.waitFor(t, "90%")347+ b.waitFor(t, "90%")348+ if strings.Contains(b.output(), "only in a") {349+ t.Error("session b shows what session a typed")350+ }351+ if n := fake.calls.Load(); n != 2 {352+ t.Errorf("%d JEV requests; want one per session", n)353+ }354+ for _, tm := range []*term{a, b} {355+ io.WriteString(tm.stdin, "\x11")356+ tm.waitFor(t, "unsaved changes")357+ io.WriteString(tm.stdin, "d")358+ tm.waitClosed(t)359+ }360+}
A internal/serve/stress_test.go +151 -0
@@ -0,0 +1,151 @@
1+//go:build stress && unix2+3+// Stress benchmark of 012 serve: many sessions at once, each over its own4+// SSH connection on loopback, measuring the heap each session holds, the5+// time from a key press leaving the client to the frame answering it6+// arriving back while every session types at once, and the CPU spent per7+// frame. The clients run in the same process, so heap and CPU include8+// their side of the connections: upper bounds for the server. Only built9+// with -tags stress (see `make stress` and docs/limits.md).10+package serve11+12+import (13+ "fmt"14+ "io"15+ "os"16+ "path/filepath"17+ "runtime"18+ "runtime/pprof"19+ "slices"20+ "sync"21+ "syscall"22+ "testing"23+ "time"24+25+ "github.com/FelineStateMachine/012/internal/stress"26+)27+28+func BenchmarkSessions(b *testing.B) {29+ for _, n := range []int{10, 50} {30+ for _, shape := range []string{"empty", "dense-1000x26"} {31+ b.Run(fmt.Sprintf("sessions=%d/%s", n, shape), func(b *testing.B) { benchSessions(b, n, shape) })32+ }33+ }34+}35+36+func benchSessions(b *testing.B, n int, shape string) {37+ key := newKey(b)38+ srv, addr, dir := testServer(b, func(o *Options) { o.MaxSessions, o.IdleTimeout = n, 0 }, key)39+ if shape != "empty" {40+ writeDense(b, filepath.Join(dir, "dense.012"))41+ }42+ before := liveHeap()43+ terms := make([]*term, n)44+ for i := range terms {45+ c, err := dial(addr, key, srv.HostKey())46+ if err != nil {47+ b.Fatal(err)48+ }49+ b.Cleanup(func() { c.Close() })50+ terms[i] = openTerm(b, c, 120, 40)51+ terms[i].waitFor(b, "READY")52+ if shape != "empty" {53+ io.WriteString(terms[i].stdin, "\x0fdense\r") // Ctrl+O dense54+ terms[i].waitFor(b, "dense.012")55+ }56+ }57+ perSession := float64(liveHeap()-before) / float64(n) / 102458+ if path := os.Getenv("SERVE_HEAP_PROFILE"); path != "" {59+ // What the sessions hold, while they're open.60+ f, err := os.Create(path)61+ if err != nil {62+ b.Fatal(err)63+ }64+ pprof.WriteHeapProfile(f)65+ f.Close()66+ }67+68+ // Every session presses a key at once, b.N times; each waits for its69+ // frame.70+ keys := []string{"\x1b[B", "\x1b[A"} // down, up71+ var mu sync.Mutex72+ var lat []time.Duration73+ cpu := cpuTime()74+ b.ResetTimer()75+ for i := 0; i < b.N; i++ {76+ var wg sync.WaitGroup77+ for _, tm := range terms {78+ wg.Go(func() {79+ d := tm.press(b, keys[i%2])80+ mu.Lock()81+ lat = append(lat, d)82+ mu.Unlock()83+ })84+ }85+ wg.Wait()86+ }87+ b.StopTimer()88+ b.ReportMetric(float64((cpuTime()-cpu).Microseconds())/float64(b.N*n), "cpu_us/frame")89+ b.ReportMetric(perSession, "KiB/session")90+ slices.Sort(lat)91+ b.ReportMetric(ms(lat[len(lat)/2]), "p50_ms")92+ b.ReportMetric(ms(lat[len(lat)*95/100]), "p95_ms")93+ b.ReportMetric(ms(lat[len(lat)-1]), "max_ms")94+}95+96+// press sends k and returns how long the first bytes of the answering97+// frame took to arrive.98+func (tm *term) press(b *testing.B, k string) time.Duration {99+ tm.mu.Lock()100+ had := tm.out.Len()101+ tm.mu.Unlock()102+ select { // forget output that arrived before the key103+ case <-tm.wrote:104+ default:105+ }106+ start := time.Now()107+ io.WriteString(tm.stdin, k)108+ timeout := time.After(10 * time.Second)109+ for {110+ select {111+ case <-tm.wrote:112+ tm.mu.Lock()113+ grew := tm.out.Len() > had114+ tm.mu.Unlock()115+ if grew {116+ return time.Since(start)117+ }118+ case <-timeout:119+ b.Fatal("no frame after a key press")120+ }121+ }122+}123+124+func writeDense(b *testing.B, path string) {125+ f, err := os.Create(path)126+ if err != nil {127+ b.Fatal(err)128+ }129+ defer f.Close()130+ if err := stress.Dense(1000, 26).Write(f); err != nil {131+ b.Fatal(err)132+ }133+}134+135+// cpuTime is the CPU time the process has used, user and system.136+func cpuTime() time.Duration {137+ var ru syscall.Rusage138+ syscall.Getrusage(syscall.RUSAGE_SELF, &ru)139+ return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())140+}141+142+// liveHeap is the heap in use after a full collection.143+func liveHeap() int64 {144+ runtime.GC()145+ runtime.GC()146+ var m runtime.MemStats147+ runtime.ReadMemStats(&m)148+ return int64(m.HeapAlloc)149+}150+151+func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
M internal/ui/download.go +17 -11
@@ -85,13 +85,17 @@ func (m *Model) openDownload(k fileio.Kind) tea.Cmd {
8585 if filepath.Ext(name) == "" {8686 name += k.Ext()8787 }88+ path, ok := m.path("download to", name)89+ if !ok {90+ return nil91+ }8892 if k.HasTables() {89- m.openTableName(name, k, r)93+ m.openTableName(name, path, k, r)9094 return nil9195 }9296 return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {93- return m.download(name, k, r, "")94- }, exists(name))97+ return m.download(name, path, k, r, "")98+ }, exists(path))9599 })96100 return nil97101 }
@@ -101,15 +105,16 @@ func exists(name string) bool {
101105 return err == nil102106 }103107 104-// openTableName asks for the table to write in a database of kind k.105-func (m *Model) openTableName(name string, k fileio.Kind, r sheet.Rect) {108+// openTableName asks for the table to write in a database of kind k,109+// name at path on disk.110+func (m *Model) openTableName(name, path string, k fileio.Kind, r sheet.Rect) {106111 m.openText("Table in "+filepath.Base(name)+":", fileio.TableName(filepath.Base(m.displayBase())), func(m *Model, table string) tea.Cmd {107112 if table == "" {108113 return nil109114 }110115 has := false111- if exists(name) {112- ts, err := fileio.Tables(context.Background(), name)116+ if exists(path) {117+ ts, err := fileio.Tables(context.Background(), path)113118 if err != nil {114119 m.fail(fmt.Sprintf("Couldn't open %s: %v", filepath.Base(name), err))115120 return nil
@@ -119,7 +124,7 @@ func (m *Model) openTableName(name string, k fileio.Kind, r sheet.Rect) {
119124 }120125 }121126 return m.confirmReplace("Table "+table+" exists in "+filepath.Base(name)+".", func(m *Model) tea.Cmd {122- return m.download(name, k, r, table)127+ return m.download(name, path, k, r, table)123128 }, has)124129 })125130 }
@@ -141,14 +146,15 @@ func (m *Model) confirmReplace(msg string, do func(*Model) tea.Cmd, replaces boo
141146 return nil142147 }143148 144-// download snapshots the sheet and writes it in the background.145-func (m *Model) download(name string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {149+// download snapshots the sheet and writes it, as name at path on disk,150+// in the background.151+func (m *Model) download(name, path string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {146152 snap := fileio.Snap(m.sheet, r, filepath.Base(m.displayBase()))147153 if k.HoldsSheets() && r == (sheet.Rect{}) {148154 snap = fileio.SnapBook(m.sheet) // every sheet, as Sheets' .xlsx download149155 }150156 return func() tea.Msg {151- res, err := fileio.Export(context.Background(), name, k, snap, fileio.ExportOptions{Table: table})157+ res, err := fileio.Export(context.Background(), path, k, snap, fileio.ExportOptions{Table: table})152158 return exportedMsg{name: name, kind: k, res: res, err: err}153159 }154160 }
M internal/ui/file.go +182 -31
@@ -2,14 +2,17 @@ package ui
22 33 import (44 "fmt"5+ "io/fs"56 "log/slog"67 "os"78 "path/filepath"89 "slices"910 "strings"11+ "time"1012 1113 tea "charm.land/bubbletea/v2"1214 15+ "github.com/FelineStateMachine/012/internal/confine"1316 "github.com/FelineStateMachine/012/internal/fileio"1417 "github.com/FelineStateMachine/012/internal/sheet"1518 "github.com/FelineStateMachine/012/internal/telemetry"
@@ -17,6 +20,43 @@ import (
1720 1821 // Saving and opening .012 files. Files are written and read in the1922 // background; other formats go through transfer.go.23+//24+// Names are kept as the user typed them and turned into paths on disk25+// only to read or write (Model.path), so a session served over SSH stays26+// inside its directory.27+28+// Serve prepares m for a session served over SSH: file names resolve29+// inside root, and env (KEY=value pairs) is the client's environment,30+// used instead of the server's to learn about the client's terminal.31+func (m *Model) Serve(root confine.Root, env []string) {32+ m.root = root33+ m.term = newTerminal(envLookup(env))34+}35+36+// envLookup reads a variable from a list of KEY=value pairs, the last37+// one winning as in os/exec.38+func envLookup(env []string) func(string) string {39+ return func(key string) string {40+ v := ""41+ for _, kv := range env {42+ if k, val, ok := strings.Cut(kv, "="); ok && k == key {43+ v = val44+ }45+ }46+ return v47+ }48+}49+50+// path is where name is on disk. It fails for a name outside the served51+// directory, saying so with verb ("open", "save") in the note.52+func (m *Model) path(verb, name string) (string, bool) {53+ p, err := m.root.Resolve(name)54+ if err != nil {55+ m.fail(fmt.Sprintf("Couldn't %s %s: %v", verb, name, err))56+ return "", false57+ }58+ return p, true59+}2060 2161 func (m *Model) openSave() tea.Cmd {2262 name := m.filename
@@ -24,7 +64,7 @@ func (m *Model) openSave() tea.Cmd {
2464 name = m.displayBase() + sheet.FileExt2565 }2666 m.openText("Save as:", name, func(m *Model, text string) tea.Cmd {27- return saveCmd(m.sheet, withExt(text))67+ return m.saveAs(withExt(text), true)2868 })2969 return nil3070 }
@@ -34,16 +74,21 @@ func (m *Model) openRetrieve() tea.Cmd {
3474 if _, ok := fileio.KindOf(text); ok {3575 return m.confirmImport(text, fileio.Options{})3676 }37- return loadCmd(withExt(text))77+ name := withExt(text)78+ p, ok := m.path("open", name)79+ if !ok {80+ return nil81+ }82+ return loadCmd(name, p)3883 })39- return listFilesCmd84+ return listFilesCmd(m.root)4085 }4186 4287 // reset starts over on s, as File > New and Open do. What belongs to the4388 // session rather than the sheet carries over: the window, theme, terminal44-// state and the JEV connection.89+// state, served directory and the JEV connection.4590 func (m *Model) reset(s *sheet.Sheet, filename string) {46- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, charts: chartState{last: -1}}91+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}}4792 if m.jev != nil {4893 s.Book().SetRemote(m.jev.cache)4994 }
@@ -56,22 +101,63 @@ func withExt(name string) string {
56101 return name57102 }58103 104+// stamp tells versions of a file on disk apart: when it was last written105+// and its size. The zero stamp stands for no file.106+type stamp struct {107+ mod time.Time108+ size int64109+}110+111+func stampOf(st fs.FileInfo) stamp { return stamp{st.ModTime(), st.Size()} }112+113+// diskStamp is the stamp of the file at path now, zero if there's none.114+func diskStamp(path string) stamp {115+ st, err := os.Stat(path)116+ if err != nil {117+ return stamp{}118+ }119+ return stampOf(st)120+}121+122+func (s stamp) equal(o stamp) bool { return s.mod.Equal(o.mod) && s.size == o.size }123+59124 type savedMsg struct {60- name string61- err error125+ name string126+ err error127+ stamp stamp // the file as written128+ conflict bool // not written: the file changed on disk since it was opened62129 }63130 64131 type loadedMsg struct {65132 name string66133 sheet *sheet.Sheet134+ stamp stamp67135 err error68136 }69137 70138 type filesMsg []string71139 72-// saveCmd writes the worksheet atomically: to a temporary file first, then73-// renamed over the target.74-func saveCmd(s *sheet.Sheet, name string) tea.Cmd {140+// saveAs saves the sheet as name. When name is the open file and check141+// is set, it first makes sure nobody else wrote the file since it was142+// opened or last saved here: another program, or another session of a143+// served directory.144+func (m *Model) saveAs(name string, check bool) tea.Cmd {145+ p, ok := m.path("save", name)146+ if !ok {147+ m.quitAfterSave = false148+ return nil149+ }150+ expect := (*stamp)(nil)151+ if check && name == m.filename {152+ expect = &m.disk153+ }154+ return saveCmd(m.sheet, name, p, expect)155+}156+157+// saveCmd writes the worksheet to path atomically: to a temporary file158+// first, then renamed over the target. With expect set, it writes only159+// if the file on disk is still that version.160+func saveCmd(s *sheet.Sheet, name, path string, expect *stamp) tea.Cmd {75161 span := telemetry.Start("save")76162 var buf strings.Builder77163 err := s.Write(&buf)
@@ -80,57 +166,121 @@ func saveCmd(s *sheet.Sheet, name string) tea.Cmd {
80166 }81167 return func() tea.Msg {82168 if err != nil {83- return savedMsg{name, err}169+ return savedMsg{name: name, err: err}170+ }171+ // A file deleted meanwhile is simply written again.172+ if now := diskStamp(path); expect != nil && now != (stamp{}) && !now.equal(*expect) {173+ return savedMsg{name: name, conflict: true}84174 }85- tmp := name + ".tmp"86- if err := os.WriteFile(tmp, []byte(buf.String()), 0o644); err != nil {87- return savedMsg{name, err}175+ if err := writeAtomic(path, buf.String()); err != nil {176+ return savedMsg{name: name, err: err}88177 }89- return savedMsg{name, os.Rename(tmp, name)}178+ return savedMsg{name: name, stamp: diskStamp(path)}179+ }180+}181+182+// writeAtomic writes data to path through a temporary file of its own183+// beside it, so two writers never share one, then renames it over path.184+func writeAtomic(path, data string) error {185+ f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")186+ if err != nil {187+ return err188+ }189+ tmp := f.Name()190+ _, err = f.WriteString(data)191+ if err == nil {192+ err = f.Chmod(0o644)193+ }194+ if cerr := f.Close(); err == nil {195+ err = cerr196+ }197+ if err == nil {198+ err = os.Rename(tmp, path)199+ }200+ if err != nil {201+ os.Remove(tmp)90202 }203+ return err91204 }92205 93-func loadCmd(name string) tea.Cmd {206+func loadCmd(name, path string) tea.Cmd {94207 return func() tea.Msg {95- f, err := os.Open(name)208+ f, err := os.Open(path)96209 if err != nil {97210 return loadedMsg{name: name, err: err}98211 }99212 defer f.Close()213+ var st stamp214+ if fi, err := f.Stat(); err == nil {215+ st = stampOf(fi)216+ }100217 span := telemetry.Start("open")101218 s, err := sheet.Read(f)102219 if err != nil {103220 span.Fail(err)104221 } else if telemetry.Enabled() {105- span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", openSize(f)))222+ span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", st.size))106223 }107- return loadedMsg{name, s, err}224+ return loadedMsg{name, s, st, err}108225 }109226 }110227 111-// openSize is the size of an open file, for telemetry; -1 if unknown.112-func openSize(f *os.File) int64 {113- st, err := f.Stat()114- if err != nil {115- return -1228+// listFilesCmd lists the .012 files in the directory names are relative229+// to.230+func listFilesCmd(root confine.Root) tea.Cmd {231+ return func() tea.Msg {232+ dir, err := root.Resolve(".")233+ if err != nil {234+ return filesMsg(nil)235+ }236+ files, _ := filepath.Glob(filepath.Join(globEscape(dir), "*"+sheet.FileExt))237+ for i, f := range files {238+ files[i] = filepath.Base(f)239+ }240+ files = slices.DeleteFunc(files, func(f string) bool { return strings.HasPrefix(f, ".") })241+ slices.Sort(files)242+ return filesMsg(files)116243 }117- return st.Size()118244 }119245 120-func listFilesCmd() tea.Msg {121- files, _ := filepath.Glob("*" + sheet.FileExt)122- slices.Sort(files)123- return filesMsg(files)246+// globEscape escapes the pattern characters in a directory name.247+func globEscape(dir string) string {248+ if dir == "." || !strings.ContainsAny(dir, `*?[\`) {249+ return dir250+ }251+ var b strings.Builder252+ for _, r := range dir {253+ if strings.ContainsRune(`*?[\`, r) {254+ b.WriteByte('\\')255+ }256+ b.WriteRune(r)257+ }258+ return b.String()124259 }125260 126261 func (m *Model) handleSaved(msg savedMsg) tea.Cmd {262+ if msg.conflict {263+ m.openOverlay(&choiceBar{264+ msg: filepath.Base(msg.name) + " changed on disk since it was opened.",265+ warn: true,266+ choices: []choice{267+ {key: "enter", label: "Overwrite", run: func(m *Model) tea.Cmd { return m.saveAs(msg.name, false) }},268+ {key: "s", label: "Save as", run: (*Model).openSave},269+ {key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {270+ m.quitAfterSave = false271+ return nil272+ }},273+ },274+ })275+ return nil276+ }127277 quit := m.quitAfterSave128278 m.quitAfterSave = false129279 if msg.err != nil {130280 m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))131281 return nil132282 }133- m.filename = msg.name283+ m.filename, m.disk = msg.name, msg.stamp134284 m.changed = false135285 if quit {136286 return m.exit()
@@ -144,6 +294,7 @@ func (m *Model) handleLoaded(msg loadedMsg) {
144294 return145295 }146296 m.reset(msg.sheet, msg.name)297+ m.disk = msg.stamp147298 }148299 149300 // save writes to the current file, asking for a name the first time.
@@ -154,5 +305,5 @@ func (m *Model) save() tea.Cmd {
154305 if m.filename == "" {155306 return m.openSave()156307 }157- return saveCmd(m.sheet, m.filename)308+ return m.saveAs(m.filename, true)158309 }
M internal/ui/graphics.go +4 -3
M internal/ui/model.go +16 -6
@@ -4,10 +4,12 @@
44 package ui55 66 import (7+ "os"78 "time"89 910 tea "charm.land/bubbletea/v2"1011 12+ "github.com/FelineStateMachine/012/internal/confine"1113 "github.com/FelineStateMachine/012/internal/sheet"1214 "github.com/FelineStateMachine/012/internal/telemetry"1315 "github.com/FelineStateMachine/012/internal/ui/theme"
@@ -59,8 +61,10 @@ type Model struct {
5961 // The file.6062 filename string6163 changed bool62- saved int // the sheet's StateID when last saved or loaded63- quitAfterSave bool // "Save and quit" is waiting for the save to finish64+ saved int // the sheet's StateID when last saved or loaded65+ quitAfterSave bool // "Save and quit" is waiting for the save to finish66+ disk stamp // the file on disk as last opened or saved here, to notice others' saves67+ root confine.Root // where file names resolve; confined when served over SSH6468 6569 mode mode6670 note string // feedback on the last action, e.g. "Undid: clear B3"
@@ -90,7 +94,11 @@ type Model struct {
9094 9195 // New returns a model editing s. filename may be empty.9296 func New(s *sheet.Sheet, filename string) *Model {93- return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), charts: chartState{last: -1}}97+ m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}}98+ if filename != "" {99+ m.disk = diskStamp(filename)100+ }101+ return m94102 }95103 96104 // Init implements tea.Model. It asks the terminal for its background color
@@ -138,10 +146,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
138146 case tea.MouseWheelMsg:139147 m.handleWheel(msg.Mouse())140148 case savedMsg:141- cmd = m.handleSaved(msg)142- if msg.err == nil {149+ if msg.err == nil && !msg.conflict {143150 m.saved = m.sheet.StateID()144151 }152+ cmd = m.handleSaved(msg)145153 case loadedMsg:146154 m.handleLoaded(msg)147155 case filesMsg:
@@ -305,7 +313,9 @@ func clampAddr(a sheet.Addr) sheet.Addr {
305313 }306314 }307315 316+// fail shows msg in ERROR mode. A served session isn't told where its317+// directory is on the server: errors name its files relative to it.308318 func (m *Model) fail(msg string) {309319 m.mode = modeError310- m.errMsg = msg320+ m.errMsg = m.root.Scrub(msg)311321 }
A internal/ui/serve_test.go +189 -0
@@ -0,0 +1,189 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "slices"7+ "strings"8+ "testing"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/confine"14+ "github.com/FelineStateMachine/012/internal/fileio"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// writeSheet saves a one-cell sheet at path.19+func writeSheet(t *testing.T, path, a1 string) {20+ t.Helper()21+ s := sheet.New()22+ s.Set(addr("A1"), a1)23+ f, err := os.Create(path)24+ if err != nil {25+ t.Fatal(err)26+ }27+ defer f.Close()28+ if err := s.Write(f); err != nil {29+ t.Fatal(err)30+ }31+}32+33+// A served session reads and writes only inside its directory, whatever34+// the server's working directory is.35+func TestServedFilesStayInside(t *testing.T) {36+ top := t.TempDir()37+ served, elsewhere := filepath.Join(top, "served"), filepath.Join(top, "elsewhere")38+ os.Mkdir(served, 0o755)39+ os.Mkdir(elsewhere, 0o755)40+ writeSheet(t, filepath.Join(served, "inside.012"), "in")41+ writeSheet(t, filepath.Join(elsewhere, "outside.012"), "out")42+ os.WriteFile(filepath.Join(served, "data.csv"), []byte("a,b\n1,2\n"), 0o644)43+ os.WriteFile(filepath.Join(elsewhere, "other.csv"), []byte("x\n"), 0o644)44+ os.Symlink("../elsewhere/outside.012", filepath.Join(served, "link.012"))45+ t.Chdir(elsewhere)46+47+ root, err := confine.New(served)48+ if err != nil {49+ t.Fatal(err)50+ }51+ m := newModel()52+ m.Serve(root, []string{"TERM=xterm-256color"})53+54+ // File > Open lists the served directory's sheets.55+ press(t, m, "<ctrl+o>")56+ if m.prompt == nil || !slices.Equal(m.prompt.files, []string{"inside.012", "link.012"}) {57+ t.Fatalf("Open lists %v", m.prompt.files)58+ }59+ press(t, m, "inside", "<enter>")60+ if m.mode != modeReady || input(m, "A1") != "in" || m.filename != "inside.012" {61+ t.Fatalf("open inside: mode %v A1 %q file %q err %q", m.mode, input(m, "A1"), m.filename, m.errMsg)62+ }63+64+ for _, name := range []string{"../elsewhere/outside", "link", filepath.Join(elsewhere, "outside")} {65+ press(t, m, "<ctrl+o>", name, "<enter>")66+ if m.mode != modeError || !strings.Contains(m.errMsg, "outside the served directory") {67+ t.Errorf("open %s: mode %v, %q", name, m.mode, m.errMsg)68+ }69+ if !filepath.IsAbs(name) && strings.Contains(m.errMsg, top) {70+ t.Errorf("the error names the server's directories: %q", m.errMsg)71+ }72+ press(t, m, "<esc>")73+ }74+75+ // Save as writes into the served directory, not the working one.76+ press(t, m, "<ctrl+s>")77+ if _, err := os.Stat(filepath.Join(served, "inside.012")); err != nil {78+ t.Fatal(err)79+ }80+ run(m, m.openSave())81+ press(t, m, "copy", "<enter>")82+ if _, err := os.Stat(filepath.Join(served, "copy.012")); err != nil {83+ t.Errorf("Save as copy: %v (%q)", err, m.errMsg)84+ }85+ if _, err := os.Stat(filepath.Join(elsewhere, "copy.012")); err == nil {86+ t.Error("Save as wrote to the working directory")87+ }88+ run(m, m.openSave())89+ press(t, m, "../escaped", "<enter>")90+ if m.mode != modeError {91+ t.Errorf("Save as ../escaped: mode %v", m.mode)92+ }93+ press(t, m, "<esc>")94+ if _, err := os.Stat(filepath.Join(top, "escaped.012")); err == nil {95+ t.Error("Save as escaped the served directory")96+ }97+98+ // The import picker lists the served directory's files.99+ names, _ := importable(m.root)100+ if !slices.Equal(names, []string{"data.csv"}) {101+ t.Errorf("importable = %v", names)102+ }103+ run(m, m.startImport("../elsewhere/other.csv", fileio.Options{}))104+ if m.mode != modeError || m.xfer.job != nil {105+ t.Errorf("import outside: mode %v job %v", m.mode, m.xfer.job)106+ }107+}108+109+// Saving over a file someone else wrote since it was opened asks first.110+func TestSaveAsksWhenFileChanged(t *testing.T) {111+ t.Chdir(t.TempDir())112+ writeSheet(t, "book.012", "first")113+ s, err := readSheet("book.012")114+ if err != nil {115+ t.Fatal(err)116+ }117+ m := New(s, "book.012")118+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 20})119+120+ // Nobody else wrote it: saving just saves.121+ press(t, m, "mine", "<enter>", "<ctrl+s>")122+ if m.overlay != nil || m.changed {123+ t.Fatalf("plain save: overlay %T changed %v", m.overlay, m.changed)124+ }125+126+ // Another session saves the file.127+ writeSheet(t, "book.012", "theirs, longer")128+ later := time.Now().Add(time.Minute)129+ os.Chtimes("book.012", later, later)130+131+ press(t, m, "<up>", "again", "<enter>", "<ctrl+s>")132+ if _, ok := m.overlay.(*choiceBar); !ok || !strings.Contains(line(m, contextLine), "book.012 changed on disk since it was opened") {133+ t.Fatalf("context line %q, overlay %T", line(m, contextLine), m.overlay)134+ }135+ press(t, m, "<esc>")136+ if !m.changed {137+ t.Error("cancelled save cleared the modified flag")138+ }139+ if got, _ := readSheet("book.012"); a1Of(got) != "theirs, longer" {140+ t.Error("cancelled save wrote the file")141+ }142+143+ // Asked again, Enter overwrites; saving after that doesn't ask.144+ press(t, m, "<ctrl+s>", "<enter>")145+ if m.overlay != nil || m.changed {146+ t.Fatalf("overwrite: overlay %T changed %v", m.overlay, m.changed)147+ }148+ if got, _ := readSheet("book.012"); a1Of(got) != "again" {149+ t.Errorf("A1 on disk %q", a1Of(got))150+ }151+ press(t, m, "<up>", "more", "<enter>", "<ctrl+s>")152+ if m.overlay != nil || m.changed {153+ t.Errorf("save after overwrite: overlay %T changed %v", m.overlay, m.changed)154+ }155+156+ // A file deleted meanwhile is written again without asking.157+ os.Remove("book.012")158+ press(t, m, "<up>", "back", "<enter>", "<ctrl+s>")159+ if got, _ := readSheet("book.012"); m.overlay != nil || a1Of(got) != "back" {160+ t.Errorf("save after delete: overlay %T, A1 on disk %q", m.overlay, a1Of(got))161+ }162+163+ // Quitting with Save and quit waits for the answer, then quits.164+ writeSheet(t, "book.012", "theirs again, longer")165+ press(t, m, "<up>", "last", "<enter>")166+ quit := press(t, m, "<ctrl+q>", "<enter>")167+ if quit != nil || !strings.Contains(line(m, contextLine), "changed on disk") {168+ t.Fatalf("Save and quit didn't ask: %v, %q", quit, line(m, contextLine))169+ }170+ if _, ok := press(t, m, "<enter>").(tea.QuitMsg); !ok {171+ t.Error("Overwrite didn't finish Save and quit")172+ }173+}174+175+func a1Of(s *sheet.Sheet) string {176+ if s == nil || s.Cell(addr("A1")) == nil {177+ return ""178+ }179+ return s.Cell(addr("A1")).Input180+}181+182+func readSheet(name string) (*sheet.Sheet, error) {183+ f, err := os.Open(name)184+ if err != nil {185+ return nil, err186+ }187+ defer f.Close()188+ return sheet.Read(f)189+}
M internal/ui/transfer.go +28 -13
@@ -13,6 +13,7 @@ import (
1313 tea "charm.land/bubbletea/v2"1414 "github.com/charmbracelet/x/ansi"1515 16+ "github.com/FelineStateMachine/012/internal/confine"1617 "github.com/FelineStateMachine/012/internal/fileio"1718 "github.com/FelineStateMachine/012/internal/ui/theme"1819 )
@@ -85,18 +86,27 @@ func (m *Model) startupCmd() tea.Cmd {
8586 return m.startImport(name, fileio.Options{})8687 }8788 88-// importable lists the files in the current directory that 012 can89-// import, by name.90-func importable() []string {91- entries, _ := os.ReadDir(".")89+// importable lists the files in the directory names are relative to90+// (the current one, or the served one) that 012 can import, by name,91+// with their sizes.92+func importable(root confine.Root) ([]string, map[string]int64) {93+ dir, err := root.Resolve(".")94+ if err != nil {95+ return nil, nil96+ }97+ entries, _ := os.ReadDir(dir)9298 var out []string99+ sizes := map[string]int64{}93100 for _, e := range entries {94101 if _, ok := fileio.KindOf(e.Name()); ok && e.Type().IsRegular() && !strings.HasPrefix(e.Name(), ".") {95102 out = append(out, e.Name())103+ if info, err := e.Info(); err == nil {104+ sizes[e.Name()] = info.Size()105+ }96106 }97107 }98108 slices.SortFunc(out, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })99- return out109+ return out, sizes100110 }101111 102112 // openImport opens the import picker: the importable files here, with
@@ -104,7 +114,7 @@ func importable() []string {
104114 // too.105115 func (m *Model) openImport() tea.Cmd {106116 var items []pickItem107- names := importable()117+ names, sizes := importable(m.root)108118 lw := 0 // types line up, and sizes after them109119 for _, name := range names {110120 k, _ := fileio.KindOf(name)
@@ -113,9 +123,9 @@ func (m *Model) openImport() tea.Cmd {
113123 for _, name := range names {114124 k, _ := fileio.KindOf(name)115125 detail, desc := k.Label(), "Import "+name+", "+strings.ToLower(k.Label()[:1])+k.Label()[1:]116- if st, err := os.Stat(name); err == nil {117- detail = fmt.Sprintf("%-*s %6s", lw, k.Label(), fileSize(st.Size()))118- desc += ", " + fileSize(st.Size())126+ if size, ok := sizes[name]; ok {127+ detail = fmt.Sprintf("%-*s %6s", lw, k.Label(), fileSize(size))128+ desc += ", " + fileSize(size)119129 }120130 items = append(items, pickItem{title: name, name: len(name), detail: detail, desc: desc,121131 pick: func(m *Model) tea.Cmd {
@@ -183,11 +193,16 @@ func (m *Model) confirmImport(name string, opt fileio.Options) tea.Cmd {
183193 // startImport reads name in the background.184194 func (m *Model) startImport(name string, opt fileio.Options) tea.Cmd {185195 m.note = ""186- return m.xfer.start(name, opt)196+ path, ok := m.path("import", name)197+ if !ok {198+ return nil199+ }200+ return m.xfer.start(name, path, opt)187201 }188202 189-// start reads name in the background, replacing any import running.190-func (x *transfer) start(name string, opt fileio.Options) tea.Cmd {203+// start reads name, at path on disk, in the background, replacing any204+// import running.205+func (x *transfer) start(name, path string, opt fileio.Options) tea.Cmd {191206 if x.job != nil {192207 x.job.cancel()193208 }
@@ -198,7 +213,7 @@ func (x *transfer) start(name string, opt fileio.Options) tea.Cmd {
198213 x.job = job199214 return tea.Batch(200215 func() tea.Msg {201- res, err := fileio.Import(ctx, name, opt)216+ res, err := fileio.Import(ctx, path, opt)202217 return importedMsg{id: job.id, name: name, opt: opt, res: res, err: err}203218 },204219 tickImport(job.id),
M oracle/go.mod +3 -3
M oracle/go.sum +6 -6
@@ -20,13 +20,13 @@ github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG
2020 github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=2121 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=2222 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=23-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=24-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=23+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=24+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=2525 golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=2626 golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=27-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=28-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=29-golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=30-golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=27+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=28+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=29+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=30+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=3131 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=3232 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
M scripts/stress/run.sh +1 -1