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