internal/store/store.go
1
package store
3
import (
4
"bytes"
5
"crypto/rand"
6
"encoding/hex"
7
"errors"
8
"fmt"
9
"io"
10
"io/fs"
11
"log/slog"
12
"os"
13
"path"
14
"path/filepath"
15
"sort"
16
"strings"
18
yaml "go.yaml.in/yaml/v3"
19
)
21
const (
22
DirName = ".koment"
24
annotationsDir = "annotations"
25
recordSuffix = ".yaml"
26
yamlIndent = 2
27
)
29
const schemaDirective = "# yaml-language-server: $schema=" + SchemaURL + "\n"
31
type Store struct{ root string }
33
func Open(root string) *Store { return &Store{root: root} }
35
func (s *Store) Root() string { return s.root }
37
func closeRepositoryRoot(root *os.Root, returnedError *error) {
38
if err := root.Close(); err != nil {
39
*returnedError = errors.Join(*returnedError, fmt.Errorf("closing repository root: %w", err))
40
}
41
}
43
// FindRoot walks up from start for the directory that owns the annotations,
44
// preferring an existing .koment over the enclosing git work tree.
45
func FindRoot(start string) (string, error) {
46
directory, err := filepath.Abs(start)
47
if err != nil {
48
return "", fmt.Errorf("resolving %s: %w", start, err)
49
}
51
gitRoot := ""
52
for {
53
if isDir(filepath.Join(directory, DirName)) {
54
return directory, nil
55
}
56
if gitRoot == "" && exists(filepath.Join(directory, ".git")) {
57
gitRoot = directory
58
}
59
parent := filepath.Dir(directory)
60
if parent == directory {
61
break
62
}
63
directory = parent
64
}
66
if gitRoot != "" {
67
return gitRoot, nil
68
}
69
return "", fmt.Errorf("no %s or .git directory at or above %s", DirName, start)
70
}
72
// FromWorkingDirectory reads a path the way a person typing it at a shell
73
// prompt means it: relative to where they are standing.
74
func (s *Store) FromWorkingDirectory(path string) (string, error) {
75
absolute, err := filepath.Abs(path)
76
if err != nil {
77
return "", fmt.Errorf("resolving %s: %w", path, err)
78
}
79
return s.fromAbsolute(absolute, path)
80
}
82
// FromRoot reads a path the way an API caller means it: already relative to the
83
// repository root, wherever the koment process happens to be running.
84
func (s *Store) FromRoot(path string) (string, error) {
85
if filepath.IsAbs(path) {
86
return s.fromAbsolute(path, path)
87
}
88
return validSourcePath(filepath.ToSlash(filepath.Clean(path)))
89
}
91
func (s *Store) fromAbsolute(absolute, original string) (string, error) {
92
relative, err := filepath.Rel(s.root, absolute)
93
if err != nil {
94
return "", fmt.Errorf("%s is not inside %s: %w", original, s.root, err)
95
}
96
return validSourcePath(filepath.ToSlash(relative))
97
}
99
func validSourcePath(value string) (string, error) {
100
if strings.Contains(value, `\`) {
101
return "", fmt.Errorf("source path %s must use forward slashes", value)
102
}
103
clean := path.Clean(value)
104
switch {
105
case clean == "" || clean == ".":
106
return "", fmt.Errorf("empty source path")
107
case clean != value:
108
return "", fmt.Errorf("source path %s is not canonical; use %s", value, clean)
109
case path.IsAbs(clean) || hasDrivePrefix(clean):
110
return "", fmt.Errorf("source path %s must be relative to the repository root", value)
111
case clean == ".." || strings.HasPrefix(clean, "../"):
112
return "", fmt.Errorf("source path %s escapes the repository root", value)
113
}
114
return clean, nil
115
}
117
func hasDrivePrefix(value string) bool {
118
if len(value) < 2 || value[1] != ':' {
119
return false
120
}
121
letter := value[0]
122
return letter >= 'A' && letter <= 'Z' || letter >= 'a' && letter <= 'z'
123
}
125
func (s *Store) ReadSource(file string) (_ []byte, returnedError error) {
126
clean, err := validSourcePath(file)
127
if err != nil {
128
return nil, err
129
}
130
root, err := os.OpenRoot(s.root)
131
if err != nil {
132
return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
133
}
134
defer closeRepositoryRoot(root, &returnedError)
135
content, err := root.ReadFile(filepath.FromSlash(clean))
136
if err != nil {
137
return nil, fmt.Errorf("reading source %s: %w", clean, err)
138
}
139
return content, nil
140
}
142
// WriteSource atomically replaces a repository file without crossing its root.
143
func (s *Store) WriteSource(file string, content []byte) (returnedError error) {
144
clean, err := validSourcePath(file)
145
if err != nil {
146
return err
147
}
148
root, err := os.OpenRoot(s.root)
149
if err != nil {
150
return fmt.Errorf("opening repository root %s: %w", s.root, err)
151
}
152
defer closeRepositoryRoot(root, &returnedError)
153
name := filepath.FromSlash(clean)
154
information, err := root.Stat(name)
155
if err != nil {
156
return fmt.Errorf("reading permissions for %s: %w", clean, err)
157
}
158
if err := writeAtomicallyWithMode(root, name, content, information.Mode().Perm()); err != nil {
159
return fmt.Errorf("writing source %s: %w", clean, err)
160
}
161
return nil
162
}
164
func (s *Store) RecordPath(id string) (string, error) {
165
name, err := recordName(id)
166
if err != nil {
167
return "", err
168
}
169
return filepath.Join(s.root, name), nil
170
}
172
func recordName(id string) (string, error) {
173
if !ValidID(id) {
174
return "", fmt.Errorf("annotation id %q is not a canonical ULID", id)
175
}
176
return filepath.Join(DirName, annotationsDir, id+recordSuffix), nil
177
}
179
func (s *Store) Load(id string) (_ *Annotation, returnedError error) {
180
name, err := recordName(id)
181
if err != nil {
182
return nil, err
183
}
184
root, err := os.OpenRoot(s.root)
185
if err != nil {
186
return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
187
}
188
defer closeRepositoryRoot(root, &returnedError)
189
content, err := root.ReadFile(name)
190
if err != nil {
191
return nil, err
192
}
193
annotation, upgraded, err := decodeAnnotation(id, content)
194
if err != nil {
195
return nil, err
196
}
197
if upgraded {
198
persistUpgrade(root, name, annotation)
199
}
200
return annotation, nil
201
}
203
func persistUpgrade(root *os.Root, name string, annotation *Annotation) {
204
encoded, err := EncodeAnnotation(annotation)
205
if err == nil {
206
err = writeAtomically(root, name, encoded)
207
}
208
if err != nil {
209
slog.Warn("koment left a record in the v1 shape", "record", name, "error", err)
210
}
211
}
213
// DecodeAnnotation validates one record read from a non-filesystem source. A
214
// v1 record is upgraded in memory; only Store.Load writes the upgrade back.
215
func DecodeAnnotation(id string, content []byte) (*Annotation, error) {
216
annotation, _, err := decodeAnnotation(id, content)
217
return annotation, err
218
}
220
type recordShape struct {
221
APIVersion string `yaml:"apiVersion"`
222
Version *int `yaml:"version"`
223
}
225
func decodeAnnotation(id string, content []byte) (*Annotation, bool, error) {
226
name, err := recordName(id)
227
if err != nil {
228
return nil, false, err
229
}
230
var shape recordShape
231
if err := yaml.Unmarshal(content, &shape); err != nil {
232
return nil, false, fmt.Errorf("parsing %s: %w", name, err)
233
}
235
annotation, upgraded, err := decodeShape(name, shape, content)
236
if err != nil {
237
return nil, false, err
238
}
239
if err := annotation.Validate(); err != nil {
240
return nil, false, fmt.Errorf("in %s: %w", name, err)
241
}
242
if annotation.Metadata.ID != id {
243
return nil, false, fmt.Errorf("in %s: record claims id %s but filename claims %s", name, annotation.Metadata.ID, id)
244
}
245
return annotation, upgraded, nil
246
}
248
func decodeShape(name string, shape recordShape, content []byte) (*Annotation, bool, error) {
249
switch {
250
case shape.APIVersion == APIVersion:
251
var annotation Annotation
252
if err := decodeOneDocument(name, content, &annotation); err != nil {
253
return nil, false, err
254
}
255
return &annotation, false, nil
256
case shape.APIVersion != "":
257
return nil, false, fmt.Errorf(
258
"incompatible %s: apiVersion %q is not supported; this binary reads %s (ADR 0119)",
259
name, shape.APIVersion, APIVersion)
260
case shape.Version != nil && *shape.Version == LegacyRecordVersion:
261
var legacy legacyRecord
262
if err := decodeOneDocument(name, content, &legacy); err != nil {
263
return nil, false, err
264
}
265
upgraded := upgradeLegacy(legacy)
266
return &upgraded, true, nil
267
default:
268
return nil, false, fmt.Errorf(
269
"incompatible %s: no apiVersion; a koment record starts with `apiVersion: %s` (ADR 0119)",
270
name, APIVersion)
271
}
272
}
274
func decodeOneDocument(name string, content []byte, into any) error {
275
decoder := yaml.NewDecoder(bytes.NewReader(content))
276
decoder.KnownFields(true)
277
if err := decoder.Decode(into); err != nil {
278
return fmt.Errorf("parsing %s: %w", name, err)
279
}
280
var trailing any
281
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
282
if err == nil {
283
return fmt.Errorf("parsing %s: multiple YAML documents are not allowed", name)
284
}
285
return fmt.Errorf("parsing %s after the annotation: %w", name, err)
286
}
287
return nil
288
}
290
func (s *Store) Save(annotation *Annotation) (returnedError error) {
291
name, err := recordName(annotation.Metadata.ID)
292
if err != nil {
293
return err
294
}
295
encoded, err := EncodeAnnotation(annotation)
296
if err != nil {
297
return err
298
}
299
root, err := os.OpenRoot(s.root)
300
if err != nil {
301
return fmt.Errorf("opening repository root %s: %w", s.root, err)
302
}
303
defer closeRepositoryRoot(root, &returnedError)
304
if err := root.MkdirAll(filepath.Join(DirName, annotationsDir), 0o755); err != nil {
305
return fmt.Errorf("creating %s: %w", filepath.Join(DirName, annotationsDir), err)
306
}
308
return writeAtomically(root, name, encoded)
309
}
311
func EncodeAnnotation(annotation *Annotation) ([]byte, error) {
312
if err := annotation.Validate(); err != nil {
313
return nil, err
314
}
315
var encoded strings.Builder
316
encoded.WriteString(schemaDirective)
317
encoder := yaml.NewEncoder(&encoded)
318
encoder.SetIndent(yamlIndent)
319
if err := encoder.Encode(annotation); err != nil {
320
return nil, fmt.Errorf("encoding annotation %s: %w", annotation.Metadata.ID, err)
321
}
322
if err := encoder.Close(); err != nil {
323
return nil, fmt.Errorf("encoding annotation %s: %w", annotation.Metadata.ID, err)
324
}
325
return []byte(encoded.String()), nil
326
}
328
func writeAtomically(root *os.Root, name string, content []byte) error {
329
return writeAtomicallyWithMode(root, name, content, 0o644)
330
}
332
func writeAtomicallyWithMode(root *os.Root, name string, content []byte, mode fs.FileMode) error {
333
var entropy [8]byte
334
if _, err := rand.Read(entropy[:]); err != nil {
335
return fmt.Errorf("creating temporary name for %s: %w", name, err)
336
}
337
temporaryName := name + "." + hex.EncodeToString(entropy[:])
338
temporary, err := root.OpenFile(temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
339
if err != nil {
340
return fmt.Errorf("creating temporary file beside %s: %w", name, err)
341
}
342
defer func() { _ = root.Remove(temporaryName) }()
344
if _, err := temporary.Write(content); err != nil {
345
_ = temporary.Close()
346
return fmt.Errorf("writing %s: %w", temporaryName, err)
347
}
348
if err := temporary.Close(); err != nil {
349
return fmt.Errorf("closing %s: %w", temporaryName, err)
350
}
351
if err := root.Rename(temporaryName, name); err != nil {
352
return fmt.Errorf("replacing %s: %w", name, err)
353
}
354
return nil
355
}
357
func (s *Store) FindByID(id string) (*Annotation, error) {
358
annotation, err := s.Load(id)
359
if errorsIsNotExist(err) {
360
return nil, fmt.Errorf("no annotation with id %s", id)
361
}
362
return annotation, err
363
}
365
func errorsIsNotExist(err error) bool {
366
return err != nil && os.IsNotExist(err)
367
}
369
func (s *Store) Remove(id string) (returnedError error) {
370
name, err := recordName(id)
371
if err != nil {
372
return err
373
}
374
root, err := os.OpenRoot(s.root)
375
if err != nil {
376
return fmt.Errorf("opening repository root %s: %w", s.root, err)
377
}
378
defer closeRepositoryRoot(root, &returnedError)
379
if err := root.Remove(name); err != nil {
380
return fmt.Errorf("removing %s: %w", name, err)
381
}
382
return nil
383
}
385
func (s *Store) All() (_ []Annotation, returnedError error) {
386
root, err := os.OpenRoot(s.root)
387
if err != nil {
388
return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
389
}
390
defer closeRepositoryRoot(root, &returnedError)
391
directory := path.Join(DirName, annotationsDir)
392
entries, err := fs.ReadDir(root.FS(), directory)
393
if errorsIsNotExist(err) {
394
return nil, nil
395
}
396
if err != nil {
397
return nil, fmt.Errorf("reading %s: %w", directory, err)
398
}
400
annotations := make([]Annotation, 0, len(entries))
401
for _, entry := range entries {
402
if entry.IsDir() {
403
return nil, fmt.Errorf("unexpected directory %s in flat annotation store", path.Join(directory, entry.Name()))
404
}
405
if !strings.HasSuffix(entry.Name(), recordSuffix) {
406
continue
407
}
408
id := strings.TrimSuffix(entry.Name(), recordSuffix)
409
annotation, err := s.Load(id)
410
if err != nil {
411
return nil, err
412
}
413
annotations = append(annotations, *annotation)
414
}
415
return annotations, nil
416
}
418
func (s *Store) ForFile(file string) ([]Annotation, error) {
419
clean, err := validSourcePath(file)
420
if err != nil {
421
return nil, err
422
}
423
all, err := s.All()
424
if err != nil {
425
return nil, err
426
}
427
annotations := make([]Annotation, 0)
428
for _, annotation := range all {
429
if annotation.Spec.Target.File == clean {
430
annotations = append(annotations, annotation)
431
}
432
}
433
return annotations, nil
434
}
436
func (s *Store) AnnotatedFiles() ([]string, error) {
437
annotations, err := s.All()
438
if err != nil {
439
return nil, err
440
}
441
unique := make(map[string]struct{}, len(annotations))
442
for _, annotation := range annotations {
443
unique[annotation.Spec.Target.File] = struct{}{}
444
}
445
files := make([]string, 0, len(unique))
446
for file := range unique {
447
files = append(files, file)
448
}
449
sort.Strings(files)
450
return files, nil
451
}
453
func isDir(path string) bool {
454
info, err := os.Stat(path)
455
return err == nil && info.IsDir()
456
}
458
func exists(path string) bool {
459
_, err := os.Stat(path)
460
return err == nil
461
}