aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-05 22:58:38 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-05 22:58:38 +0200
commitf482480b6ee6971b4e436f9ce901b991fb269644 (patch)
tree84d96a4e083ebb723a86e28a747fbfea64d36dd6
parent016b10e33161ec3274cf4b4d34eb939a290a9243 (diff)
kstore: add atomic document storev0.10.0
-rw-r--r--README.md4
-rw-r--r--kstore/codec.go36
-rw-r--r--kstore/store.go62
-rw-r--r--kstore/store_test.go92
4 files changed, 194 insertions, 0 deletions
diff --git a/README.md b/README.md
index def2674..f2567af 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,10 @@ block, err := aes.NewCipher(secret)
kcore.Expect(err, "error creating AES cipher")
```
+## kstore
+
+Provides typed JSON and YAML document loading with atomic writes and explicit file permissions.
+
## kauth
- [ ] Auto logout for some errors (unreachable user, expired)
diff --git a/kstore/codec.go b/kstore/codec.go
new file mode 100644
index 0000000..6a1568f
--- /dev/null
+++ b/kstore/codec.go
@@ -0,0 +1,36 @@
+package kstore
+
+import (
+ "encoding/json"
+
+ "gopkg.in/yaml.v3"
+)
+
+// JSONCodec encodes documents as indented JSON.
+type JSONCodec struct{}
+
+func (JSONCodec) Marshal(value any) ([]byte, error) {
+ return json.MarshalIndent(value, "", " ")
+}
+
+func (JSONCodec) Unmarshal(data []byte, value any) error {
+ return json.Unmarshal(data, value)
+}
+
+// YAMLCodec encodes documents as YAML.
+type YAMLCodec struct{}
+
+func (YAMLCodec) Marshal(value any) ([]byte, error) {
+ return yaml.Marshal(value)
+}
+
+func (YAMLCodec) Unmarshal(data []byte, value any) error {
+ return yaml.Unmarshal(data, value)
+}
+
+var (
+ // JSON is the default JSON codec.
+ JSON JSONCodec
+ // YAML is the default YAML codec.
+ YAML YAMLCodec
+)
diff --git a/kstore/store.go b/kstore/store.go
new file mode 100644
index 0000000..7d1beec
--- /dev/null
+++ b/kstore/store.go
@@ -0,0 +1,62 @@
+// Package kstore provides durable, typed document files.
+package kstore
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// Codec serializes and deserializes document values.
+type Codec interface {
+ Marshal(value any) ([]byte, error)
+ Unmarshal(data []byte, value any) error
+}
+
+// Load reads and decodes a document from path.
+func Load[T any](path string, codec Codec) (T, error) {
+ var value T
+
+ data, err := os.ReadFile(path) // #nosec G304 -- Callers explicitly provide the document path.
+ if err != nil {
+ return value, fmt.Errorf("read document %q: %w", path, err)
+ }
+ if err := codec.Unmarshal(data, &value); err != nil {
+ return value, fmt.Errorf("decode document %q: %w", path, err)
+ }
+
+ return value, nil
+}
+
+// Save encodes value and atomically replaces path with the requested mode.
+func Save(path string, value any, codec Codec, mode os.FileMode) error {
+ data, err := codec.Marshal(value)
+ if err != nil {
+ return fmt.Errorf("encode document %q: %w", path, err)
+ }
+
+ directory := filepath.Dir(path)
+ temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+".tmp-*")
+ if err != nil {
+ return fmt.Errorf("create temporary document for %q: %w", path, err)
+ }
+ temporaryPath := temporary.Name()
+ defer os.Remove(temporaryPath) //nolint:errcheck // Best-effort cleanup after close or rename.
+
+ if err := temporary.Chmod(mode); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("set permissions on temporary document for %q: %w", path, err)
+ }
+ if _, err := temporary.Write(data); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("write temporary document for %q: %w", path, err)
+ }
+ if err := temporary.Close(); err != nil {
+ return fmt.Errorf("close temporary document for %q: %w", path, err)
+ }
+ if err := os.Rename(temporaryPath, path); err != nil {
+ return fmt.Errorf("replace document %q: %w", path, err)
+ }
+
+ return nil
+}
diff --git a/kstore/store_test.go b/kstore/store_test.go
new file mode 100644
index 0000000..bbbd97a
--- /dev/null
+++ b/kstore/store_test.go
@@ -0,0 +1,92 @@
+package kstore
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type document struct {
+ Name string `json:"name" yaml:"name"`
+ Count int `json:"count" yaml:"count"`
+}
+
+func TestJSONRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "document.json")
+ want := document{Name: "example", Count: 3}
+
+ require.NoError(t, Save(path, want, JSON, 0o600))
+ got, err := Load[document](path, JSON)
+
+ require.NoError(t, err)
+ assert.Equal(t, want, got)
+}
+
+func TestYAMLRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "document.yaml")
+ want := document{Name: "example", Count: 3}
+
+ require.NoError(t, Save(path, want, YAML, 0o640))
+ got, err := Load[document](path, YAML)
+
+ require.NoError(t, err)
+ assert.Equal(t, want, got)
+}
+
+func TestLoadMalformedDocument(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "document.json")
+ require.NoError(t, os.WriteFile(path, []byte("not json"), 0o600))
+
+ _, err := Load[document](path, JSON)
+
+ assert.Error(t, err)
+}
+
+func TestLoadMissingDocument(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "missing.json")
+
+ _, err := Load[document](path, JSON)
+
+ assert.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func TestSaveSetsRequestedPermissions(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "document.json")
+
+ require.NoError(t, Save(path, document{}, JSON, 0o640))
+ info, err := os.Stat(path)
+
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o640), info.Mode().Perm())
+}
+
+func TestSaveFailedWriteLeavesExistingDocumentUnchanged(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "document.json")
+ require.NoError(t, Save(path, document{Name: "original"}, JSON, 0o600))
+
+ err := Save(filepath.Join(path, "child"), document{Name: "replacement"}, JSON, 0o600)
+
+ assert.Error(t, err)
+ loaded, loadErr := Load[document](path, JSON)
+ require.NoError(t, loadErr)
+ assert.Equal(t, "original", loaded.Name)
+}
+
+func TestSaveReturnsCodecErrors(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "document.json")
+ codec := failingCodec{err: errors.New("codec failed")}
+
+ err := Save(path, document{}, codec, 0o600)
+
+ assert.ErrorIs(t, err, codec.err)
+}
+
+type failingCodec struct{ err error }
+
+func (c failingCodec) Marshal(any) ([]byte, error) { return nil, c.err }
+
+func (c failingCodec) Unmarshal([]byte, any) error { return c.err }