clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / store / file.go
file.go 4.9K
1package store23import (4 "bytes"5 "errors"6 "fmt"7 "io/fs"8 "log"9 "os"10 "path/filepath"11 "slices"12 "strings"13 "time"14)1516// 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.2223// BackupDays is how many daily backups of a file are kept.24const BackupDays = 72526// ErrCorrupt is ReadFile's error when neither the file nor any backup of27// it can be read.28var ErrCorrupt = errors.New("unreadable, and no backup could be read")2930// now is the clock, for tests.31var now = time.Now3233// 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.38func 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}5556// daily keeps prev, the file before the day's first save, in backups/.57func 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}7677// backups lists path's daily backups, newest first.78func 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}9293// 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.99func 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}133134// writeAtomic replaces path with raw: a temporary file beside it, synced,135// renamed over it.136func 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}