One way of saving the server's records (store.WriteFile): atomically and synced, the file replaced kept as .bak, and the first save of a day keeps the file as it was in backups/, a week of them. A file that can't be read is set aside as .corrupt-<time> and the newest readable backup read and put back, instead of starting empty; a config with no good backup leaves the defaults rather than a server that won't start.
- commit
- b15ab0845c3b1b1e2e8f8a00c82e7e8c3e28ddd4
- parent
- 05a14fcd3a6fe9ee28890acb5ad176b1d7a14a34
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- feat risk score 1.9 of 3, Jev's estimate
| M | internal/ | 49 | +++------ |
| M | internal/ | 25 | ++++- |
| M | internal/ | 42 | ++++++++ |
| M | internal/ | 20 | ++- |
| M | internal/ | 57 | +++------- |
| M | internal/ | 7 | +- |
| A | internal/ | 168 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 162 | +++++++++++++++++++++++++++++ |
@@ -24,6 +24,7 @@ import (
2424 2525 "airwaves/internal/guide"2626 "airwaves/internal/lineup"27+ "airwaves/internal/store"2728 "airwaves/internal/tuner"2829 )2930 @@ -216,23 +217,27 @@ type Manager struct {
216217 }217218 218219 // Open loads the rules and recordings kept at path (created on first219-// save), recording into dir. A file that can't be read is set aside, as220-// path.bad, and the DVR starts empty rather than not at all.220+// save), recording into dir. A file that can't be read is set aside and221+// its backup read instead (store.ReadFile); with none, the DVR starts222+// empty rather than not at all.221223 func Open(path, dir string, tuners Tuners, locate Locate) (*Manager, error) {222224 m := &Manager{path: path, dir: dir, tuners: tuners, locate: locate, jobs: map[string]*job{},223225 st: fileState{Progress: map[string]Progress{}}}224226 m.ctx, m.cancel = context.WithCancelCause(context.Background())225- raw, err := os.ReadFile(path)227+ err := store.ReadFile(path, func(raw []byte) error {228+ var st fileState229+ if err := json.Unmarshal(raw, &st); err != nil {230+ return err231+ }232+ m.st = st233+ return nil234+ })226235 switch {227- case errors.Is(err, os.ErrNotExist):228- case err != nil:229- return nil, err236+ case err == nil, errors.Is(err, os.ErrNotExist):237+ case errors.Is(err, store.ErrCorrupt):238+ log.Printf("recordings: %v; starting empty", err)230239 default:231- if err := json.Unmarshal(raw, &m.st); err != nil {232- log.Printf("recordings: %s can't be read (%v); set aside as %s.bad, starting empty", path, err, path)233- _ = os.Rename(path, path+".bad")234- m.st = fileState{}235- }240+ return nil, err236241 }237242 if m.st.Progress == nil {238243 m.st.Progress = map[string]Progress{}@@ -246,27 +251,7 @@ func (m *Manager) save() error {
246251 if err != nil {247252 return err248253 }249- if err := os.MkdirAll(filepath.Dir(m.path), 0o755); err != nil {250- return err251- }252- f, err := os.CreateTemp(filepath.Dir(m.path), filepath.Base(m.path)+".*")253- if err != nil {254- return err255- }256- _, err = f.Write(raw)257- if err == nil {258- err = f.Sync()259- }260- if cerr := f.Close(); err == nil {261- err = cerr262- }263- if err == nil {264- err = os.Rename(f.Name(), m.path)265- }266- if err != nil {267- os.Remove(f.Name())268- }269- return err254+ return store.WriteFile(m.path, raw, 0o644)270255 }271256 272257 // saveLogged saves, logging a failure. Called with m.mu held.@@ -334,6 +334,25 @@ func TestPersistence(t *testing.T) {
334334 if len(again.st.Rules) != 1 || again.st.Rules[0].SeriesID != "SH1" {335335 t.Fatalf("reloaded rules %+v", again.st.Rules)336336 }337+338+ // Damaged, the file is read from its backup.339+ e.m.mu.Lock()340+ e.m.st.Prefs.DeleteWatchedAfterDays = 7341+ err = e.m.save()342+ e.m.mu.Unlock()343+ if err != nil {344+ t.Fatal(err)345+ }346+ if err := os.WriteFile(e.m.path, []byte(`{"rules": [`), 0o644); err != nil {347+ t.Fatal(err)348+ }349+ restored, err := Open(e.m.path, e.m.dir, e.m.tuners, locate)350+ if err != nil {351+ t.Fatal(err)352+ }353+ if len(restored.st.Rules) != 1 || restored.st.Prefs.DeleteWatchedAfterDays != 0 {354+ t.Fatalf("from the backup: rules %+v, prefs %+v", restored.st.Rules, restored.st.Prefs)355+ }337356 }338357 339358 // TestCorruptFile: a state file that can't be read is set aside, and the@@ -352,7 +371,11 @@ func TestCorruptFile(t *testing.T) {
352371 if len(m.st.Rules) != 0 {353372 t.Fatal("rules from a corrupt file")354373 }355- if raw, err := os.ReadFile(path + ".bad"); err != nil || string(raw) != "{not json" {374+ aside, _ := filepath.Glob(path + ".corrupt-*")375+ if len(aside) != 1 {376+ t.Fatalf("the corrupt file wasn't kept aside: %v", aside)377+ }378+ if raw, err := os.ReadFile(aside[0]); err != nil || string(raw) != "{not json" {356379 t.Fatalf("the corrupt file wasn't kept aside: %v", err)357380 }358381 }@@ -6,6 +6,7 @@ import (
66 "fmt"77 "net/http"88 "net/http/httptest"9+ "os"910 "path/filepath"1011 "slices"1112 "strings"@@ -201,3 +202,44 @@ func TestWeatherStarElsewhere(t *testing.T) {
201202 t.Errorf("redirect to a display that's gone: %d", rec.Code)202203 }203204 }205+206+// TestConfigSurvives: a damaged config is read from its backup, and with207+// none the server starts with the defaults rather than not at all.208+func TestConfigSurvives(t *testing.T) {209+ ctx := t.Context()210+ dir := t.TempDir()211+ path := filepath.Join(dir, "config.json")212+ open := func() *Service {213+ t.Helper()214+ s, err := New(Options{Mode: "server", NoAntenna: true, CacheDir: dir, ConfigPath: path})215+ if err != nil {216+ t.Fatal(err)217+ }218+ t.Cleanup(s.Close)219+ return s220+ }221+ s := open()222+ for _, h := range []int{48, 36} {223+ if _, err := s.SetConfig(ctx, Config{GuideHours: h}); err != nil {224+ t.Fatal(err)225+ }226+ }227+ if err := os.WriteFile(path, []byte(`{"guideHours": 3`), 0o644); err != nil {228+ t.Fatal(err)229+ }230+ if cfg, _ := open().Config(ctx); cfg.GuideHours != 48 {231+ t.Errorf("from the backup: %+v", cfg)232+ }233+234+ for _, p := range []string{path, path + ".bak"} {235+ if err := os.WriteFile(p, []byte("nonsense"), 0o644); err != nil {236+ t.Fatal(err)237+ }238+ }239+ if err := os.RemoveAll(filepath.Join(dir, "backups")); err != nil {240+ t.Fatal(err)241+ }242+ if cfg, _ := open().Config(ctx); cfg.GuideHours != 24 {243+ t.Errorf("with no backup: %+v", cfg)244+ }245+}@@ -14,11 +14,11 @@ import (
1414 "errors"1515 "fmt"1616 "io"17+ "io/fs"1718 "log"1819 "net"1920 "net/http"2021 "net/url"21- "os"2222 "path/filepath"2323 "slices"2424 "strconv"@@ -244,13 +244,22 @@ func New(opt Options) (*Service, error) {
244244 s := &Service{opt: opt, http: web.NewClient(), timing: defaultTiming}245245 s.ctx, s.cancel = context.WithCancel(context.Background())246246 s.cfg = s.withDefaults(opt.Config)247+ // A saved config that can't be read, nor any backup of it, leaves the248+ // defaults rather than a server that won't start.247249 if opt.ConfigPath != "" {248- if raw, err := os.ReadFile(opt.ConfigPath); err == nil {250+ err := store.ReadFile(opt.ConfigPath, func(raw []byte) error {249251 var c Config250252 if err := json.Unmarshal(raw, &c); err != nil {251- return nil, fmt.Errorf("parse %s: %w", opt.ConfigPath, err)253+ return err252254 }253255 s.cfg = s.withDefaults(c)256+ return nil257+ })258+ switch {259+ case errors.Is(err, store.ErrCorrupt):260+ log.Printf("config: %v; using the defaults", err)261+ case err != nil && !errors.Is(err, fs.ErrNotExist):262+ return nil, err254263 }255264 }256265 var err error@@ -472,10 +481,7 @@ func (s *Service) SetConfig(_ context.Context, c Config) (Config, error) {
472481 s.mu.Unlock()473482 if s.opt.ConfigPath != "" {474483 raw, _ := json.MarshalIndent(c, "", " ")475- if err := os.MkdirAll(filepath.Dir(s.opt.ConfigPath), 0o755); err != nil {476- return c, err477- }478- if err := os.WriteFile(s.opt.ConfigPath, raw, 0o644); err != nil {484+ if err := store.WriteFile(s.opt.ConfigPath, raw, 0o644); err != nil {479485 return c, err480486 }481487 }@@ -15,12 +15,11 @@ import (
1515 "fmt"1616 "io/fs"1717 "math"18- "os"19- "path/filepath"2018 "slices"2119 "sync"2220 "time"2321 22+ "airwaves/internal/store"2423 "airwaves/internal/tuner"2524 )2625 @@ -272,25 +271,31 @@ type file struct {
272271 }273272 274273 // Open loads the measurements saved at path, if any. An empty path keeps275-// them in memory only. A file that can't be read is set aside (as276-// path.bad) and the store starts empty.274+// them in memory only. A file that can't be read is set aside and its275+// backup read instead (store.ReadFile); with none, the store starts empty276+// and the error says so.277277 func Open(path string) (*Store, error) {278278 s := &Store{path: path, muxes: map[int64]*Mux{}}279279 if path == "" {280280 return s, nil281281 }282- raw, err := os.ReadFile(path)283- if errors.Is(err, fs.ErrNotExist) {282+ var f file283+ err := store.ReadFile(path, func(raw []byte) error {284+ var x file285+ if err := json.Unmarshal(raw, &x); err != nil {286+ return err287+ }288+ f = x289+ return nil290+ })291+ switch {292+ case errors.Is(err, fs.ErrNotExist):284293 return s, nil285- }286- if err != nil {294+ case errors.Is(err, store.ErrCorrupt):295+ return s, fmt.Errorf("signal measurements: %w; starting empty", err)296+ case err != nil:287297 return nil, err288298 }289- var f file290- if err := json.Unmarshal(raw, &f); err != nil {291- _ = os.Rename(path, path+".bad")292- return s, fmt.Errorf("signal measurements in %s unreadable, set aside: %w", path, err)293- }294299 for _, m := range f.Muxes {295300 if m != nil && m.FrequencyHz > 0 {296301 s.muxes[m.FrequencyHz] = m@@ -383,7 +388,7 @@ func (s *Store) Save() error {
383388 slices.SortFunc(f.Muxes, func(a, b *Mux) int { return cmp.Compare(a.FrequencyHz, b.FrequencyHz) })384389 raw, err := json.MarshalIndent(f, "", " ")385390 if err == nil {386- err = writeAtomic(s.path, raw)391+ err = store.WriteFile(s.path, raw, 0o644)387392 }388393 if err != nil {389394 s.mu.Lock()@@ -405,30 +410,6 @@ func (s *Store) SaveEvery(every time.Duration) error {
405410 return s.Save()406411 }407412 408-func writeAtomic(path string, raw []byte) error {409- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {410- return err411- }412- tmp, err := os.CreateTemp(filepath.Dir(path), ".signal-*")413- if err != nil {414- return err415- }416- if _, err := tmp.Write(raw); err != nil {417- tmp.Close()418- os.Remove(tmp.Name())419- return err420- }421- if err := tmp.Close(); err != nil {422- os.Remove(tmp.Name())423- return err424- }425- if err := os.Chmod(tmp.Name(), 0o644); err != nil {426- os.Remove(tmp.Name())427- return err428- }429- return os.Rename(tmp.Name(), path)430-}431-432413 // Frequency is the center frequency (Hz) of US broadcast RF channel rf,433414 // 0 for one that isn't.434415 func Frequency(rf int) int64 {@@ -143,7 +143,8 @@ func TestStoreSaves(t *testing.T) {
143143 t.Errorf("RF 36 = %+v", m)144144 }145145 146- // An unreadable file is set aside rather than lost or fatal.146+ // An unreadable file is set aside rather than lost or fatal, with no147+ // backup (one save made none) starting empty.147148 if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {148149 t.Fatal(err)149150 }@@ -151,7 +152,7 @@ func TestStoreSaves(t *testing.T) {
151152 if err == nil || bad == nil || len(bad.All()) != 0 {152153 t.Errorf("unreadable: %v, %+v", err, bad)153154 }154- if _, err := os.Stat(path + ".bad"); err != nil {155- t.Errorf("not set aside: %v", err)155+ if aside, _ := filepath.Glob(path + ".corrupt-*"); len(aside) != 1 {156+ t.Errorf("not set aside: %v", aside)156157 }157158 }@@ -0,0 +1,168 @@
1+package store2+3+import (4+ "bytes"5+ "errors"6+ "fmt"7+ "io/fs"8+ "log"9+ "os"10+ "path/filepath"11+ "slices"12+ "strings"13+ "time"14+)15+16+// The server's own records (recordings, measurements, its configuration)17+// are written so that neither a crash nor a mistake loses them: each save18+// replaces the file atomically and keeps the one it replaces as .bak, and19+// the first save of a day keeps the file as it was in backups/ beside it,20+// a week of them. A file that can't be read is set aside and the newest21+// backup read instead.22+23+// BackupDays is how many daily backups of a file are kept.24+const BackupDays = 725+26+// ErrCorrupt is ReadFile's error when neither the file nor any backup of27+// it can be read.28+var ErrCorrupt = errors.New("unreadable, and no backup could be read")29+30+// now is the clock, for tests.31+var now = time.Now32+33+// WriteFile replaces path with raw atomically, synced to disk, with mode.34+// The file it replaces is kept as path.bak; on the first save of a day35+// (UTC) it's also copied to backups/<name>.<date> beside path, and the36+// copies beyond BackupDays removed. Writing what the file already holds37+// does nothing.38+func WriteFile(path string, raw []byte, mode fs.FileMode) error {39+ prev, err := os.ReadFile(path)40+ switch {41+ case err == nil && bytes.Equal(prev, raw):42+ return nil43+ case err == nil && len(prev) > 0:44+ if err := writeAtomic(path+".bak", prev, mode); err != nil {45+ return err46+ }47+ if err := daily(path, prev, mode); err != nil {48+ log.Printf("backup of %s: %v", path, err)49+ }50+ case err != nil && !errors.Is(err, fs.ErrNotExist):51+ return err52+ }53+ return writeAtomic(path, raw, mode)54+}55+56+// daily keeps prev, the file before the day's first save, in backups/.57+func daily(path string, prev []byte, mode fs.FileMode) error {58+ dir := filepath.Join(filepath.Dir(path), "backups")59+ name := filepath.Base(path) + "."60+ today := filepath.Join(dir, name+now().UTC().Format(time.DateOnly))61+ if _, err := os.Stat(today); err == nil {62+ return nil63+ }64+ if err := writeAtomic(today, prev, mode); err != nil {65+ return err66+ }67+ old := backups(path)68+ for len(old) > BackupDays {69+ if err := os.Remove(old[len(old)-1]); err != nil {70+ return err71+ }72+ old = old[:len(old)-1]73+ }74+ return nil75+}76+77+// backups lists path's daily backups, newest first.78+func backups(path string) []string {79+ name := filepath.Base(path) + "."80+ entries, _ := os.ReadDir(filepath.Join(filepath.Dir(path), "backups"))81+ var out []string82+ for _, e := range entries {83+ day, ok := strings.CutPrefix(e.Name(), name)84+ if _, err := time.Parse(time.DateOnly, day); ok && err == nil {85+ out = append(out, filepath.Join(filepath.Dir(path), "backups", e.Name()))86+ }87+ }88+ slices.Sort(out)89+ slices.Reverse(out)90+ return out91+}92+93+// ReadFile reads path with parse. A missing file is an error matching94+// fs.ErrNotExist: a fresh start. One that's empty or that parse rejects is95+// set aside, as path.corrupt-<Unix time>, and the newest of path.bak and96+// the daily backups that parse accepts is read instead, and put back as97+// path. parse is called afresh for each, and must keep nothing from a98+// call that fails. When nothing can be read the error matches ErrCorrupt.99+func ReadFile(path string, parse func([]byte) error) error {100+ raw, err := os.ReadFile(path)101+ if err != nil {102+ return err103+ }104+ err = errors.New("empty")105+ if len(bytes.TrimSpace(raw)) > 0 {106+ if err = parse(raw); err == nil {107+ return nil108+ }109+ }110+ mode := fs.FileMode(0o644)111+ if st, serr := os.Stat(path); serr == nil {112+ mode = st.Mode().Perm()113+ }114+ aside := fmt.Sprintf("%s.corrupt-%d", path, now().Unix())115+ log.Printf("WARNING: %s can't be read (%v); set aside as %s, trying its backups", path, err, aside)116+ if rerr := os.Rename(path, aside); rerr != nil {117+ log.Printf("set aside %s: %v", path, rerr)118+ }119+ for _, b := range append([]string{path + ".bak"}, backups(path)...) {120+ raw, berr := os.ReadFile(b)121+ if berr != nil || len(bytes.TrimSpace(raw)) == 0 || parse(raw) != nil {122+ continue123+ }124+ log.Printf("WARNING: %s read from its backup %s instead", path, b)125+ if werr := writeAtomic(path, raw, mode); werr != nil {126+ log.Printf("restore %s: %v", path, werr)127+ }128+ return nil129+ }130+ log.Printf("WARNING: %s: no backup of it could be read", path)131+ return fmt.Errorf("%s: %w (%v)", path, ErrCorrupt, err)132+}133+134+// writeAtomic replaces path with raw: a temporary file beside it, synced,135+// renamed over it.136+func writeAtomic(path string, raw []byte, mode fs.FileMode) error {137+ dir := filepath.Dir(path)138+ if err := os.MkdirAll(dir, 0o755); err != nil {139+ return err140+ }141+ f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")142+ if err != nil {143+ return err144+ }145+ _, err = f.Write(raw)146+ if err == nil {147+ err = f.Chmod(mode)148+ }149+ if err == nil {150+ err = f.Sync()151+ }152+ if cerr := f.Close(); err == nil {153+ err = cerr154+ }155+ if err == nil {156+ err = os.Rename(f.Name(), path)157+ }158+ if err != nil {159+ os.Remove(f.Name())160+ return err161+ }162+ // The rename itself, to disk; not every system can sync a directory.163+ if d, err := os.Open(dir); err == nil {164+ _ = d.Sync()165+ d.Close()166+ }167+ return nil168+}@@ -0,0 +1,162 @@
1+package store2+3+import (4+ "encoding/json"5+ "errors"6+ "io/fs"7+ "os"8+ "path/filepath"9+ "testing"10+ "time"11+)12+13+type doc struct {14+ N int `json:"n"`15+}16+17+func save(t *testing.T, path string, n int) {18+ t.Helper()19+ raw, _ := json.Marshal(doc{N: n})20+ if err := WriteFile(path, raw, 0o644); err != nil {21+ t.Fatal(err)22+ }23+}24+25+func load(path string) (doc, error) {26+ var d doc27+ err := ReadFile(path, func(raw []byte) error {28+ var x doc29+ if err := json.Unmarshal(raw, &x); err != nil {30+ return err31+ }32+ d = x33+ return nil34+ })35+ return d, err36+}37+38+func read(t *testing.T, path string) string {39+ t.Helper()40+ raw, err := os.ReadFile(path)41+ if err != nil {42+ t.Fatal(err)43+ }44+ return string(raw)45+}46+47+// clock sets the day for daily backups.48+func clock(t *testing.T, at time.Time) {49+ t.Helper()50+ now = func() time.Time { return at }51+ t.Cleanup(func() { now = time.Now })52+}53+54+// TestWriteKeepsBak: each save keeps the file it replaces as .bak.55+func TestWriteKeepsBak(t *testing.T) {56+ clock(t, time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC))57+ path := filepath.Join(t.TempDir(), "dvr.json")58+ save(t, path, 1)59+ if _, err := os.Stat(path + ".bak"); !errors.Is(err, fs.ErrNotExist) {60+ t.Errorf("a first save left a .bak: %v", err)61+ }62+ save(t, path, 2)63+ save(t, path, 3)64+ if got := read(t, path); got != `{"n":3}` {65+ t.Errorf("file: %s", got)66+ }67+ if got := read(t, path+".bak"); got != `{"n":2}` {68+ t.Errorf(".bak: %s", got)69+ }70+ // The day's backup is the file before the day's first save.71+ if got := read(t, filepath.Join(filepath.Dir(path), "backups", "dvr.json.2026-10-12")); got != `{"n":1}` {72+ t.Errorf("daily backup: %s", got)73+ }74+ // Saving the same again changes nothing.75+ save(t, path, 3)76+ if got := read(t, path+".bak"); got != `{"n":2}` {77+ t.Errorf(".bak after an unchanged save: %s", got)78+ }79+ if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o644 {80+ t.Errorf("mode: %v, %v", fi.Mode(), err)81+ }82+}83+84+// TestDailyBackupsKeepAWeek: one backup a day, the last BackupDays.85+func TestDailyBackupsKeepAWeek(t *testing.T) {86+ path := filepath.Join(t.TempDir(), "signal.json")87+ day := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)88+ for i := range 10 {89+ clock(t, day.AddDate(0, 0, i))90+ save(t, path, 2*i)91+ save(t, path, 2*i+1)92+ }93+ got := backups(path)94+ if len(got) != BackupDays {95+ t.Fatalf("%d backups: %v", len(got), got)96+ }97+ if filepath.Base(got[0]) != "signal.json.2026-10-10" || filepath.Base(got[BackupDays-1]) != "signal.json.2026-10-04" {98+ t.Errorf("backups: %v", got)99+ }100+ // The 10th's is the file as the 9th left it.101+ if s := read(t, got[0]); s != `{"n":17}` {102+ t.Errorf("newest backup: %s", s)103+ }104+}105+106+// TestCorruptFallsBack: an unreadable file is set aside and its .bak read107+// and put back; with no good .bak, the newest good daily backup.108+func TestCorruptFallsBack(t *testing.T) {109+ clock(t, time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC))110+ path := filepath.Join(t.TempDir(), "config.json")111+ save(t, path, 1)112+ save(t, path, 2)113+ if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {114+ t.Fatal(err)115+ }116+ if d, err := load(path); err != nil || d.N != 1 {117+ t.Fatalf("from .bak: %+v, %v", d, err)118+ }119+ if got := read(t, path); got != `{"n":1}` {120+ t.Errorf("not put back: %s", got)121+ }122+ aside, _ := filepath.Glob(path + ".corrupt-*")123+ if len(aside) != 1 || read(t, aside[0]) != "{not json" {124+ t.Errorf("set aside: %v", aside)125+ }126+127+ // An empty file and a bad .bak: the daily backup.128+ clock(t, time.Date(2026, 10, 13, 18, 0, 0, 0, time.UTC))129+ save(t, path, 5) // backs up {"n":1} for the 13th130+ for _, p := range []string{path, path + ".bak"} {131+ if err := os.WriteFile(p, nil, 0o644); err != nil {132+ t.Fatal(err)133+ }134+ }135+ if d, err := load(path); err != nil || d.N != 1 {136+ t.Fatalf("from the daily backup: %+v, %v", d, err)137+ }138+139+ // Nothing readable at all.140+ if err := os.RemoveAll(filepath.Join(filepath.Dir(path), "backups")); err != nil {141+ t.Fatal(err)142+ }143+ for _, p := range []string{path, path + ".bak"} {144+ if err := os.WriteFile(p, []byte("[]"), 0o644); err != nil {145+ t.Fatal(err)146+ }147+ }148+ if _, err := load(path); !errors.Is(err, ErrCorrupt) {149+ t.Errorf("nothing readable: %v", err)150+ }151+}152+153+// TestMissingIsFresh: no file is a fresh start, not a corrupt one.154+func TestMissingIsFresh(t *testing.T) {155+ path := filepath.Join(t.TempDir(), "dvr.json")156+ if _, err := load(path); !errors.Is(err, fs.ErrNotExist) {157+ t.Errorf("missing: %v", err)158+ }159+ if m, _ := filepath.Glob(path + "*"); len(m) != 0 {160+ t.Errorf("files made: %v", m)161+ }162+}