diff --git a/pkg/db/statefile/statefile.go b/pkg/db/statefile/statefile.go new file mode 100644 index 0000000..fc62524 --- /dev/null +++ b/pkg/db/statefile/statefile.go @@ -0,0 +1,92 @@ +package statefile + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + FileMode = 0o600 + DirMode = 0o700 +) + +type File[T any] struct { + path string +} + +func New[T any](path string) *File[T] { + return &File[T]{path: path} +} + +func (f *File[T]) Path() string { + return f.path +} + +func (f *File[T]) Load() (T, error) { + var value T + + raw, err := os.ReadFile(f.path) + if errors.Is(err, os.ErrNotExist) { + return value, f.Save(value) + } + if err != nil { + return value, fmt.Errorf("read %s: %w", f.path, err) + } + if len(raw) == 0 { + return value, nil + } + if err := json.Unmarshal(raw, &value); err != nil { + var zero T + return zero, fmt.Errorf("parse %s: %w", f.path, err) + } + return value, nil +} + +func (f *File[T]) Save(value T) error { + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode state for %s: %w", f.path, err) + } + + dir := filepath.Dir(f.path) + if err := os.MkdirAll(dir, DirMode); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, filepath.Base(f.path)+".tmp") + if err != nil { + return fmt.Errorf("create temp state in %s: %w", dir, err) + } + defer os.Remove(tmp.Name()) + + if err := tmp.Chmod(FileMode); err != nil { + tmp.Close() + return fmt.Errorf("chmod %s: %w", tmp.Name(), err) + } + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return fmt.Errorf("write %s: %w", tmp.Name(), err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("sync %s: %w", tmp.Name(), err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close %s: %w", tmp.Name(), err) + } + + if err := os.Rename(tmp.Name(), f.path); err != nil { + return fmt.Errorf("rename %s to %s: %w", tmp.Name(), f.path, err) + } + return nil +} + +func (f *File[T]) Remove() error { + if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", f.path, err) + } + return nil +} diff --git a/pkg/db/statefile/statefile_test.go b/pkg/db/statefile/statefile_test.go new file mode 100644 index 0000000..70e2a85 --- /dev/null +++ b/pkg/db/statefile/statefile_test.go @@ -0,0 +1,189 @@ +package statefile + +import ( + "os" + "path/filepath" + "testing" +) + +type payload struct { + Name string `json:"name"` + Items []string `json:"items"` +} + +func path(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "component.state") +} + +func TestLoad_CreatesTheFileWhenAbsent(t *testing.T) { + p := path(t) + f := New[payload](p) + + value, err := f.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if value.Name != "" || len(value.Items) != 0 { + t.Errorf("value = %+v, want the zero value", value) + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("the file must be created on load: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600", got) + } +} + +func TestLoad_CreatesTheDirectoryWhenAbsent(t *testing.T) { + p := filepath.Join(t.TempDir(), "nested", "deeper", "component.state") + + if _, err := New[payload](p).Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + info, err := os.Stat(filepath.Dir(p)) + if err != nil { + t.Fatalf("the directory must be created: %v", err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Errorf("directory mode = %o, want 700", got) + } +} + +func TestLoad_EmptyFileYieldsTheZeroValue(t *testing.T) { + p := path(t) + if err := os.WriteFile(p, nil, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + value, err := New[payload](p).Load() + if err != nil { + t.Fatalf("an empty file is a valid starting point: %v", err) + } + if value.Name != "" { + t.Errorf("value = %+v, want the zero value", value) + } +} + +func TestLoad_CorruptedFileIsReported(t *testing.T) { + p := path(t) + if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, err := New[payload](p).Load(); err == nil { + t.Fatal("a corrupted file must be reported, not silently ignored") + } +} + +func TestSave_RoundTrips(t *testing.T) { + p := path(t) + f := New[payload](p) + + want := payload{Name: "vp-admin_br-000001", Items: []string{"a", "b"}} + if err := f.Save(want); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := New[payload](p).Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Name != want.Name || len(got.Items) != len(want.Items) { + t.Errorf("value = %+v, want %+v", got, want) + } +} + +func TestSave_RestoresTheModeAfterAnExternalChmod(t *testing.T) { + p := path(t) + f := New[payload](p) + if _, err := f.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if err := os.Chmod(p, 0o644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + if err := f.Save(payload{Name: "x"}); err != nil { + t.Fatalf("Save: %v", err) + } + + info, err := os.Stat(p) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("mode = %o, want 600: each save must replace the file, not edit it in place", got) + } +} + +func TestSave_LeavesNoTemporaryFileBehind(t *testing.T) { + p := path(t) + f := New[payload](p) + + for range 3 { + if err := f.Save(payload{Name: "x"}); err != nil { + t.Fatalf("Save: %v", err) + } + } + + entries, err := os.ReadDir(filepath.Dir(p)) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 { + t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries) + } +} + +func TestSave_DoesNotTruncateOnEncodingFailure(t *testing.T) { + p := path(t) + good := New[payload](p) + if err := good.Save(payload{Name: "kept"}); err != nil { + t.Fatalf("Save: %v", err) + } + + broken := New[chan int](p) + if err := broken.Save(make(chan int)); err == nil { + t.Fatal("an unencodable value must be reported") + } + + got, err := good.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.Name != "kept" { + t.Errorf("value = %+v, want the previous state untouched", got) + } +} + +func TestRemove_DeletesTheFile(t *testing.T) { + p := path(t) + f := New[payload](p) + if _, err := f.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + if err := f.Remove(); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("the file must be gone, got %v", err) + } +} + +func TestRemove_OnAnAbsentFileIsNotAnError(t *testing.T) { + if err := New[payload](path(t)).Remove(); err != nil { + t.Errorf("removing an absent file must be idempotent, got %v", err) + } +} + +func TestPath_ReportsTheFileItOwns(t *testing.T) { + p := path(t) + if got := New[payload](p).Path(); got != p { + t.Errorf("Path = %s, want %s", got, p) + } +}