A new internal/confine package maps typed names to paths. Unconfined (the local app) names are used as typed; confined, absolute paths, .. escapes, hidden files and links leading outside are refused. The UI resolves every open, save, import, download and listing through the model's root, and takes TMUX from a given environment rather than the process's, so a session served over SSH sees its own directory and terminal. Saving over the open file now checks it wasn't written by someone else since it was opened or saved here, and asks on the context line before overwriting. Saves go through a temporary file of their own, so two writers never share one.
- commit
- 69ce041e212492e29885fe0c2f6e6b82765bf997
- parent
- 9a70f51870c8a0b2c3fc005c4c3d9d0d9c341482
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 134 | +++++++++++++++++++ |
| A | internal/ | 144 | ++++++++++++++++++++ |
| M | internal/ | 28 | ++-- |
| M | internal/ | 212 | ++++++++++++++++++++++++++---- |
| M | internal/ | 7 | +- |
| M | internal/ | 22 | ++- |
| A | internal/ | 182 | ++++++++++++++++++++++++++ |
| M | internal/ | 41 | ++++-- |
@@ -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+}@@ -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+}@@ -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 }@@ -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,120 @@ 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+ if expect != nil && !diskStamp(path).equal(*expect) {172+ return savedMsg{name: name, conflict: true}84173 }85- tmp := name + ".tmp"86- if err := os.WriteFile(tmp, []byte(buf.String()), 0o644); err != nil {87- return savedMsg{name, err}174+ if err := writeAtomic(path, buf.String()); err != nil {175+ return savedMsg{name: name, err: err}88176 }89- return savedMsg{name, os.Rename(tmp, name)}177+ return savedMsg{name: name, stamp: diskStamp(path)}178+ }179+}180+181+// writeAtomic writes data to path through a temporary file of its own182+// beside it, so two writers never share one, then renames it over path.183+func writeAtomic(path, data string) error {184+ f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")185+ if err != nil {186+ return err187+ }188+ tmp := f.Name()189+ _, err = f.WriteString(data)190+ if err == nil {191+ err = f.Chmod(0o644)192+ }193+ if cerr := f.Close(); err == nil {194+ err = cerr195+ }196+ if err == nil {197+ err = os.Rename(tmp, path)198+ }199+ if err != nil {200+ os.Remove(tmp)90201 }202+ return err91203 }92204 93-func loadCmd(name string) tea.Cmd {205+func loadCmd(name, path string) tea.Cmd {94206 return func() tea.Msg {95- f, err := os.Open(name)207+ f, err := os.Open(path)96208 if err != nil {97209 return loadedMsg{name: name, err: err}98210 }99211 defer f.Close()212+ var st stamp213+ if fi, err := f.Stat(); err == nil {214+ st = stampOf(fi)215+ }100216 span := telemetry.Start("open")101217 s, err := sheet.Read(f)102218 if err != nil {103219 span.Fail(err)104220 } else if telemetry.Enabled() {105- span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", openSize(f)))221+ span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", st.size))106222 }107- return loadedMsg{name, s, err}223+ return loadedMsg{name, s, st, err}108224 }109225 }110226 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 -1227+// listFilesCmd lists the .012 files in the directory names are relative228+// to.229+func listFilesCmd(root confine.Root) tea.Cmd {230+ return func() tea.Msg {231+ dir, err := root.Resolve(".")232+ if err != nil {233+ return filesMsg(nil)234+ }235+ files, _ := filepath.Glob(filepath.Join(globEscape(dir), "*"+sheet.FileExt))236+ for i, f := range files {237+ files[i] = filepath.Base(f)238+ }239+ files = slices.DeleteFunc(files, func(f string) bool { return strings.HasPrefix(f, ".") })240+ slices.Sort(files)241+ return filesMsg(files)116242 }117- return st.Size()118243 }119244 120-func listFilesCmd() tea.Msg {121- files, _ := filepath.Glob("*" + sheet.FileExt)122- slices.Sort(files)123- return filesMsg(files)245+// globEscape escapes the pattern characters in a directory name.246+func globEscape(dir string) string {247+ if dir == "." || !strings.ContainsAny(dir, `*?[\`) {248+ return dir249+ }250+ var b strings.Builder251+ for _, r := range dir {252+ if strings.ContainsRune(`*?[\`, r) {253+ b.WriteByte('\\')254+ }255+ b.WriteRune(r)256+ }257+ return b.String()124258 }125259 126260 func (m *Model) handleSaved(msg savedMsg) tea.Cmd {261+ if msg.conflict {262+ m.openOverlay(&choiceBar{263+ msg: filepath.Base(msg.name) + " changed on disk since it was opened.",264+ warn: true,265+ choices: []choice{266+ {key: "enter", label: "Overwrite", run: func(m *Model) tea.Cmd { return m.saveAs(msg.name, false) }},267+ {key: "s", label: "Save as", run: (*Model).openSave},268+ {key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {269+ m.quitAfterSave = false270+ return nil271+ }},272+ },273+ })274+ return nil275+ }127276 quit := m.quitAfterSave128277 m.quitAfterSave = false129278 if msg.err != nil {130279 m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))131280 return nil132281 }133- m.filename = msg.name282+ m.filename, m.disk = msg.name, msg.stamp134283 m.changed = false135284 if quit {136285 return m.exit()@@ -144,6 +293,7 @@ func (m *Model) handleLoaded(msg loadedMsg) {
144293 return145294 }146295 m.reset(msg.sheet, msg.name)296+ m.disk = msg.stamp147297 }148298 149299 // save writes to the current file, asking for a name the first time.@@ -154,5 +304,5 @@ func (m *Model) save() tea.Cmd {
154304 if m.filename == "" {155305 return m.openSave()156306 }157- return saveCmd(m.sheet, m.filename)307+ return m.saveAs(m.filename, true)158308 }@@ -4,7 +4,6 @@ import (
44 "fmt"55 "image/color"66 "log/slog"7- "os"87 "strconv"98 "strings"109 @@ -34,8 +33,10 @@ type terminal struct {
3433 blurred bool // the terminal window doesn't have focus3534 }3635 37-func newTerminal() terminal {38- return terminal{tmux: os.Getenv("TMUX") != "", palette: map[int]color.RGBA{}, sent: map[int]string{}}36+// newTerminal starts with what getenv, the terminal's environment, says:37+// the process's own locally, the client's when served over SSH.38+func newTerminal(getenv func(string) string) terminal {39+ return terminal{tmux: getenv("TMUX") != "", palette: map[int]color.RGBA{}, sent: map[int]string{}}3940 }4041 4142 // wrap prepares a sequence for the outer terminal.@@ -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 }@@ -0,0 +1,182 @@
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+ // Quitting with Save and quit waits for the answer, then quits.157+ writeSheet(t, "book.012", "theirs again, longer")158+ press(t, m, "<up>", "last", "<enter>")159+ quit := press(t, m, "<ctrl+q>", "<enter>")160+ if quit != nil || !strings.Contains(line(m, contextLine), "changed on disk") {161+ t.Fatalf("Save and quit didn't ask: %v, %q", quit, line(m, contextLine))162+ }163+ if _, ok := press(t, m, "<enter>").(tea.QuitMsg); !ok {164+ t.Error("Overwrite didn't finish Save and quit")165+ }166+}167+168+func a1Of(s *sheet.Sheet) string {169+ if s == nil || s.Cell(addr("A1")) == nil {170+ return ""171+ }172+ return s.Cell(addr("A1")).Input173+}174+175+func readSheet(name string) (*sheet.Sheet, error) {176+ f, err := os.Open(name)177+ if err != nil {178+ return nil, err179+ }180+ defer f.Close()181+ return sheet.Read(f)182+}@@ -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),