Long keys keep their end after a hash, so a recovery file's name fits.
- commit
- 593087df4ad668d5a33c62368da8a47b365a2202
- parent
- 92e5253fa14897097fc97c6f1c28f2d8bd5d5af4
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.7 of 3, Jev's estimate
| M | internal/ | 1 | + |
| M | internal/ | 4 | + |
| M | internal/ | 86 | ++++++++++++++++++++++++------ |
| M | internal/ | 5 | +- |
@@ -84,6 +84,7 @@ type Model struct {
8484 root confine.Root // where file names resolve; confined when served over SSH8585 start *string // the file a served session opens first (OpenOnStart); nil once opened8686 recovered string // the recovery file restored into this book, removed once it's saved: recovery.go87+ offerKept bool // offer the recovery file kept for the file once started (OfferKept)8788 8889 mode mode8990 protectOK bool // an edit to a protected range was agreed to: protect.go@@ -31,6 +31,10 @@ type Settings struct {
3131 // Connect makes a JEV client for the configured service with key.3232 Connect func(key string) (jev.Client, error)3333 Notes []string // said on the context line at startup, e.g. config warnings34+ // RecoveryDir is where a local session keeps its unsaved work when35+ // it stops on an internal error, and finds it again: recovery.go.36+ // "" keeps none; 012 serve keeps them in the served directory.37+ RecoveryDir string3438 }3539 3640 // prefs is the settings in effect: the component behind File >@@ -2,6 +2,8 @@ package ui
22 33 import (44 "bytes"5+ "crypto/sha256"6+ "encoding/hex"57 "errors"68 "fmt"79 "io/fs"@@ -27,6 +29,11 @@ import (
2729 // back. The directory is hidden, so names typed in a session never reach2830 // it (internal/confine); only this file reads and writes it. See2931 // docs/terminal/ssh.md.32+//33+// A local session keeps them too when it stops on an internal error34+// (crash.go), in Settings.RecoveryDir, keyed by the file's absolute path,35+// and offers them back when that file is opened again. See36+// docs/files/saving.md.3037 3138 // RecoveryDir is the directory, inside the served one, recovery files3239 // are kept in.@@ -36,6 +43,9 @@ const (
3643 recoveryKeep = 3 // recovery files kept per file name; older ones are removed3744 recoveryUntitled = "(untitled)" // the key for a book never saved; "(" is escaped in file names3845 recoveryStamp = "20060102-150405"46+ // recoveryKeyMax keeps a recovery file's name (the key, the time and47+ // .012) under the 255 bytes file systems allow.48+ recoveryKeyMax = 2003949 )4050 4151 // recoveryName matches a recovery file's name after its key and "-":@@ -50,9 +60,19 @@ func (m *Model) OpenOnStart(name string) {
5060 m.start = &name5161 }5262 63+// OfferKept makes a local session offer, once it starts, the changes64+// kept for its file (or for an untitled book) when 012 last stopped on65+// an internal error. cmd/012 asks for it when started on a file or on66+// nothing, not on an import or standard input.67+func (m *Model) OfferKept() { m.offerKept = true }68+5369 // startOpenCmd opens what OpenOnStart set, once, when the program starts.5470 func (m *Model) startOpenCmd() tea.Cmd {5571 if m.start == nil {72+ if m.offerKept {73+ m.offerKept = false74+ m.offerRecovery(m.filename)75+ }5676 return nil5777 }5878 name := *m.start@@ -101,22 +121,19 @@ func (m *Model) Unsaved() bool {
101121 func (m *Model) Filename() string { return m.filename }102122 103123 // Recover writes the workbook to a new recovery file for its name,104-// removing the oldest beyond recoveryKeep, and returns the file's name105-// relative to the served directory. It's for 012 serve, when the program106-// has stopped; a model that isn't served has nowhere to put one.124+// removing the oldest beyond recoveryKeep, and returns the file's name:125+// relative to the served directory in 012 serve, its full path in a126+// local session. It's for when the program has stopped; a local model127+// without Settings.RecoveryDir has nowhere to put one.107128 func (m *Model) Recover(now time.Time) (string, error) {108- if !m.root.Confined() {109- return "", errors.New("recovery files are only kept for served sessions")129+ dir, key, err := m.recoveryPlace(m.filename, true)130+ if err != nil {131+ return "", err110132 }111133 var buf bytes.Buffer112134 if err := m.sheet.Write(&buf); err != nil {113135 return "", err114136 }115- dir, err := recoveryDir(m.root.Dir(), true)116- if err != nil {117- return "", err118- }119- key := recoveryKey(m.filename)120137 base := key + "-" + now.Format(recoveryStamp)121138 for i := 1; i < 100; i++ {122139 name := base + sheet.FileExt@@ -139,11 +156,41 @@ func (m *Model) Recover(now time.Time) (string, error) {
139156 return "", err140157 }141158 prune(dir, key)159+ if !m.root.Confined() {160+ return filepath.Join(dir, name), nil161+ }142162 return filepath.Join(RecoveryDir, name), nil143163 }144164 return "", errors.New("too many recovery files this second")145165 }146166 167+// recoveryPlace is the directory recovery files for name are kept in,168+// made when create is set, and the start of their names there.169+func (m *Model) recoveryPlace(name string, create bool) (dir, key string, err error) {170+ if m.root.Confined() {171+ dir, err = recoveryDir(m.root.Dir(), create)172+ return dir, recoveryKey(name), err173+ }174+ dir = m.prefs.RecoveryDir175+ if dir == "" {176+ return "", "", errors.New("this session keeps no recovery files")177+ }178+ if create {179+ if err := os.MkdirAll(dir, 0o700); err != nil {180+ return "", "", err181+ }182+ }183+ if st, err := os.Stat(dir); err != nil || !st.IsDir() {184+ return "", "", fmt.Errorf("no recovery directory at %s", dir)185+ }186+ if name != "" {187+ if abs, err := filepath.Abs(name); err == nil {188+ name = abs189+ }190+ }191+ return dir, recoveryKey(name), nil192+}193+147194 // recoveryDir is the recovery directory in root, made 0700 when create148195 // is set. It must be a directory of its own, not a link elsewhere.149196 func recoveryDir(root string, create bool) (string, error) {@@ -165,13 +212,19 @@ func recoveryDir(root string, create bool) (string, error) {
165212 166213 // recoveryKey is the start of the recovery files' names for a file name167214 // as typed: the name without its extension, escaped so any path is one168-// file name, or recoveryUntitled for a book never saved.215+// file name, or recoveryUntitled for a book never saved. A key too long216+// for a file name keeps its end after a hash of the whole.169217 func recoveryKey(name string) string {170218 if name == "" {171219 return recoveryUntitled172220 }173221 name = filepath.ToSlash(filepath.Clean(name))174- return url.PathEscape(strings.TrimSuffix(name, filepath.Ext(name)))222+ key := url.PathEscape(strings.TrimSuffix(name, filepath.Ext(name)))223+ if len(key) > recoveryKeyMax {224+ sum := sha256.Sum256([]byte(key))225+ key = hex.EncodeToString(sum[:8]) + "-" + key[len(key)-(recoveryKeyMax-17):]226+ }227+ return key175228 }176229 177230 // recoveryFile is a recovery file found for a name.@@ -219,16 +272,13 @@ func prune(dir, key string) {
219272 }220273 221274 // offerRecovery asks, on the context line, whether to restore the222-// newest recovery file kept for name, when a served session opens it.275+// newest recovery file kept for name, when a session opens it.223276 func (m *Model) offerRecovery(name string) {224- if !m.root.Confined() {225- return226- }227- dir, err := recoveryDir(m.root.Dir(), false)277+ dir, key, err := m.recoveryPlace(name, false)228278 if err != nil {229279 return230280 }231- files := recoveries(dir, recoveryKey(name))281+ files := recoveries(dir, key)232282 if len(files) == 0 {233283 return234284 }@@ -39,7 +39,8 @@ func TestRecoveryKey(t *testing.T) {
3939 }4040 }4141 42-// Only changed books that hold something are kept, only when served,42+// Only changed books that hold something are kept, only when served (or43+// given a recovery directory, recovery tests in crash_test.go),4344 // and at most recoveryKeep per name, newest kept.4445 func TestRecoverKeepsFew(t *testing.T) {4546 local := newModel()@@ -123,7 +124,7 @@ func TestRecoveryOfferUntitled(t *testing.T) {
123124 124125 // Opening a file with kept changes offers them; restoring makes them125126 // unsaved changes to that file, and saving them removes the recovery126-// file. A local model never offers.127+// file. A local model without a recovery directory never offers.127128 func TestRecoveryRestore(t *testing.T) {128129 m, dir := servedModel(t)129130 writeSheet(t, filepath.Join(dir, "book.012"), "on disk")