internal/store/store.go
1
package store
3
import (
4
"crypto/rand"
5
"encoding/hex"
6
"errors"
7
"fmt"
8
"io"
9
"io/fs"
10
"os"
11
"path"
12
"path/filepath"
13
"sort"
14
"strings"
16
yaml "go.yaml.in/yaml/v3"
17
)
19
const (
20
DirName = ".koment"
22
annotationsDir = "annotations"
23
recordSuffix = ".yaml"
24
yamlIndent = 2
25
)
27
const schemaDirective = "# yaml-language-server: $schema=" + SchemaURL + "\n"
29
type Store struct{ root string }
31
func Open(root string) *Store { return &Store{root: root} }
33
func (s *Store) Root() string { return s.root }
35
func closeRepositoryRoot(root *os.Root, returnedError *error) {
36
if err := root.Close(); err != nil {
37
*returnedError = errors.Join(*returnedError, fmt.Errorf("closing repository root: %w", err))
38
}
39
}
41
// FindRoot walks up from start for the directory that owns the annotations,
42
// preferring an existing .koment over the enclosing git work tree.
43
func FindRoot(start string) (string, error) {
44
directory, err := filepath.Abs(start)
45
if err != nil {
46
return "", fmt.Errorf("resolving %s: %w", start, err)
47
}
49
gitRoot := ""
50
for {
51
if isDir(filepath.Join(directory, DirName)) {
52
return directory, nil
53
}
54
if gitRoot == "" && exists(filepath.Join(directory, ".git")) {
55
gitRoot = directory
56
}
57
parent := filepath.Dir(directory)
58
if parent == directory {
59
break
60
}
61
directory = parent
62
}
64
if gitRoot != "" {
65
return gitRoot, nil
66
}
67
return "", fmt.Errorf("no %s or .git directory at or above %s", DirName, start)
68
}
70
// FromWorkingDirectory reads a path the way a person typing it at a shell
71
// prompt means it: relative to where they are standing.
72
func (s *Store) FromWorkingDirectory(path string) (string, error) {
73
absolute, err := filepath.Abs(path)
74
if err != nil {
75
return "", fmt.Errorf("resolving %s: %w", path, err)
76
}
77
return s.fromAbsolute(absolute, path)
78
}
80
// FromRoot reads a path the way an API caller means it: already relative to the
81
// repository root, wherever the koment process happens to be running.
82
func (s *Store) FromRoot(path string) (string, error) {
83
if filepath.IsAbs(path) {
84
return s.fromAbsolute(path, path)
85
}
86
return validSourcePath(filepath.ToSlash(filepath.Clean(path)))
87
}
89
func (s *Store) fromAbsolute(absolute, original string) (string, error) {
90
relative, err := filepath.Rel(s.root, absolute)
91
if err != nil {
92
return "", fmt.Errorf("%s is not inside %s: %w", original, s.root, err)
93
}
94
return validSourcePath(filepath.ToSlash(relative))
95
}
97
func validSourcePath(value string) (string, error) {
98
if strings.Contains(value, `\`) {
99
return "", fmt.Errorf("source path %s must use forward slashes", value)
100
}
101
clean := path.Clean(value)
102
switch {
103
case clean == "" || clean == ".":
104
return "", fmt.Errorf("empty source path")
105
case clean != value:
106
return "", fmt.Errorf("source path %s is not canonical; use %s", value, clean)
107
case path.IsAbs(clean) || hasDrivePrefix(clean):
108
return "", fmt.Errorf("source path %s must be relative to the repository root", value)
109
case clean == ".." || strings.HasPrefix(clean, "../"):
110
return "", fmt.Errorf("source path %s escapes the repository root", value)
111
}
112
return clean, nil
113
}
115
func hasDrivePrefix(value string) bool {
116
if len(value) < 2 || value[1] != ':' {
117
return false
118
}
119
letter := value[0]
120
return letter >= 'A' && letter <= 'Z' || letter >= 'a' && letter <= 'z'
121
}
123
func (s *Store) ReadSource(file string) (_ []byte, returnedError error) {
124
clean, err := validSourcePath(file)
125
if err != nil {
126
return nil, err
127
}
128
root, err := os.OpenRoot(s.root)
129
if err != nil {
130
return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
131
}
132
defer closeRepositoryRoot(root, &returnedError)
133
content, err := root.ReadFile(filepath.FromSlash(clean))
134
if err != nil {
135
return nil, fmt.Errorf("reading source %s: %w", clean, err)
136
}
137
return content, nil
138
}
140
// WriteSource atomically replaces a repository file without crossing its root.
141
func (s *Store) WriteSource(file string, content []byte) (returnedError error) {
142
clean, err := validSourcePath(file)
143
if err != nil {
144
return err
145
}
146
root, err := os.OpenRoot(s.root)
147
if err != nil {
148
return fmt.Errorf("opening repository root %s: %w", s.root, err)
149
}
150
defer closeRepositoryRoot(root, &returnedError)
151
name := filepath.FromSlash(clean)
152
information, err := root.Stat(name)
153
if err != nil {
154
return fmt.Errorf("reading permissions for %s: %w", clean, err)
155
}
156
if err := writeAtomicallyWithMode(root, name, content, information.Mode().Perm()); err != nil {
157
return fmt.Errorf("writing source %s: %w", clean, err)
158
}
159
return nil
160
}
162
func (s *Store) RecordPath(id string) (string, error) {
163
name, err := recordName(id)
164
if err != nil {
165
return "", err
166
}
167
return filepath.Join(s.root, name), nil
168
}
170
func recordName(id string) (string, error) {
171
if !ValidID(id) {
172
return "", fmt.Errorf("annotation id %q is not a canonical ULID", id)
173
}
174
return filepath.Join(DirName, annotationsDir, id+recordSuffix), nil
175
}
177
func (s *Store) Load(id string) (_ *Annotation, returnedError error) {
178
name, err := recordName(id)
179
if err != nil {
180
return nil, err
181
}
182
root, err := os.OpenRoot(s.root)
183
if err != nil {
184
return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
185
}
186
defer closeRepositoryRoot(root, &returnedError)
187
content, err := root.ReadFile(name)
188
if err != nil {
189
return nil, err
190
}
191
return DecodeAnnotation(id, content)
192
}
194
// DecodeAnnotation validates one record read from a non-filesystem source.
195
func DecodeAnnotation(id string, content []byte) (*Annotation, error) {
196
name, err := recordName(id)
197
if err != nil {
198
return nil, err
199
}
200
var annotation Annotation
201
decoder := yaml.NewDecoder(strings.NewReader(string(content)))
202
decoder.KnownFields(true)
203
if err := decoder.Decode(&annotation); err != nil {
204
return nil, fmt.Errorf("parsing %s: %w", name, err)
205
}
206
var trailing any
207
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
208
if err == nil {
209
return nil, fmt.Errorf("parsing %s: multiple YAML documents are not allowed", name)
210
}
211
return nil, fmt.Errorf("parsing %s after the annotation: %w", name, err)
212
}
213
if err := annotation.Validate(); err != nil {
214
return nil, fmt.Errorf("in %s: %w", name, err)
215
}
216
if annotation.ID != id {
217
return nil, fmt.Errorf("in %s: record claims id %s but filename claims %s", name, annotation.ID, id)
218
}
219
return &annotation, nil
220
}
222
func (s *Store) Save(annotation *Annotation) (returnedError error) {
223
name, err := recordName(annotation.ID)
224
if err != nil {
225
return err
226
}
227
encoded, err := EncodeAnnotation(annotation)
228
if err != nil {
229
return err
230
}
231
root, err := os.OpenRoot(s.root)
232
if err != nil {
233
return fmt.Errorf("opening repository root %s: %w", s.root, err)
234
}
235
defer closeRepositoryRoot(root, &returnedError)
236
if err := root.MkdirAll(filepath.Join(DirName, annotationsDir), 0o755); err != nil {
237
return fmt.Errorf("creating %s: %w", filepath.Join(DirName, annotationsDir), err)
238
}
240
return writeAtomically(root, name, encoded)
241
}
243
func EncodeAnnotation(annotation *Annotation) ([]byte, error) {
244
if err := annotation.Validate(); err != nil {
245
return nil, err
246
}
247
var encoded strings.Builder
248
encoded.WriteString(schemaDirective)
249
encoder := yaml.NewEncoder(&encoded)
250
encoder.SetIndent(yamlIndent)
251
if err := encoder.Encode(annotation); err != nil {
252
return nil, fmt.Errorf("encoding annotation %s: %w", annotation.ID, err)
253
}
254
if err := encoder.Close(); err != nil {
255
return nil, fmt.Errorf("encoding annotation %s: %w", annotation.ID, err)
256
}
257
return []byte(encoded.String()), nil
258
}
260
func writeAtomically(root *os.Root, name string, content []byte) error {
261
return writeAtomicallyWithMode(root, name, content, 0o644)
262
}
264
func writeAtomicallyWithMode(root *os.Root, name string, content []byte, mode fs.FileMode) error {
265
var entropy [8]byte
266
if _, err := rand.Read(entropy[:]); err != nil {
267
return fmt.Errorf("creating temporary name for %s: %w", name, err)
268
}
269
temporaryName := name + "." + hex.EncodeToString(entropy[:])
270
temporary, err := root.OpenFile(temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
271
if err != nil {
272
return fmt.Errorf("creating temporary file beside %s: %w", name, err)
273
}
274
defer func() { _ = root.Remove(temporaryName) }()
276
if _, err := temporary.Write(content); err != nil {
277
_ = temporary.Close()
278
return fmt.Errorf("writing %s: %w", temporaryName, err)
279
}
280
if err := temporary.Close(); err != nil {
281
return fmt.Errorf("closing %s: %w", temporaryName, err)
282
}
283
if err := root.Rename(temporaryName, name); err != nil {
284
return fmt.Errorf("replacing %s: %w", name, err)
285
}
286
return nil
287
}
289
func (s *Store) FindByID(id string) (*Annotation, error) {
290
annotation, err := s.Load(id)
291
if errorsIsNotExist(err) {
292
return nil, fmt.Errorf("no annotation with id %s", id)
293
}
294
return annotation, err
295
}
297
func errorsIsNotExist(err error) bool {
298
return err != nil && os.IsNotExist(err)
299
}
301
func (s *Store) Remove(id string) (returnedError error) {
302
name, err := recordName(id)
303
if err != nil {
304
return err
305
}
306
root, err := os.OpenRoot(s.root)
307
if err != nil {
308
return fmt.Errorf("opening repository root %s: %w", s.root, err)
309
}
310
defer closeRepositoryRoot(root, &returnedError)
311
if err := root.Remove(name); err != nil {
312
return fmt.Errorf("removing %s: %w", name, err)
313
}
314
return nil
315
}
317
func (s *Store) All() (_ []Annotation, returnedError error) {
318
root, err := os.OpenRoot(s.root)
319
if err != nil {
320
return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
321
}
322
defer closeRepositoryRoot(root, &returnedError)
323
directory := path.Join(DirName, annotationsDir)
324
entries, err := fs.ReadDir(root.FS(), directory)
325
if errorsIsNotExist(err) {
326
return nil, nil
327
}
328
if err != nil {
329
return nil, fmt.Errorf("reading %s: %w", directory, err)
330
}
332
annotations := make([]Annotation, 0, len(entries))
333
for _, entry := range entries {
334
if entry.IsDir() {
335
return nil, fmt.Errorf("unexpected directory %s in flat annotation store", path.Join(directory, entry.Name()))
336
}
337
if !strings.HasSuffix(entry.Name(), recordSuffix) {
338
continue
339
}
340
id := strings.TrimSuffix(entry.Name(), recordSuffix)
341
annotation, err := s.Load(id)
342
if err != nil {
343
return nil, err
344
}
345
annotations = append(annotations, *annotation)
346
}
347
return annotations, nil
348
}
350
func (s *Store) ForFile(file string) ([]Annotation, error) {
351
clean, err := validSourcePath(file)
352
if err != nil {
353
return nil, err
354
}
355
all, err := s.All()
356
if err != nil {
357
return nil, err
358
}
359
annotations := make([]Annotation, 0)
360
for _, annotation := range all {
361
if annotation.File == clean {
362
annotations = append(annotations, annotation)
363
}
364
}
365
return annotations, nil
366
}
368
func (s *Store) AnnotatedFiles() ([]string, error) {
369
annotations, err := s.All()
370
if err != nil {
371
return nil, err
372
}
373
unique := make(map[string]struct{}, len(annotations))
374
for _, annotation := range annotations {
375
unique[annotation.File] = struct{}{}
376
}
377
files := make([]string, 0, len(unique))
378
for file := range unique {
379
files = append(files, file)
380
}
381
sort.Strings(files)
382
return files, nil
383
}
385
func isDir(path string) bool {
386
info, err := os.Stat(path)
387
return err == nil && info.IsDir()
388
}
390
func exists(path string) bool {
391
_, err := os.Stat(path)
392
return err == nil
393
}