• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / stash / 19942116358

04 Dec 2025 07:57PM UTC coverage: 82.861% (+0.2%) from 82.633%
19942116358

push

github

web-flow
feat(web): add key history viewing and restore functionality (#25)

* feat(web): add key history viewing and restore functionality

Add history viewing and one-click restore for keys when git versioning is enabled.

- Add History() and GetRevision() methods to git package
- Add GET /kv/history/{key...} API endpoint returning revision list with base64-encoded values
- Add web handlers for history modal, revision view, and restore action
- Add history.html and revision.html templates
- Add CSS styles for history table
- Update README with API documentation and screenshots

* fix(web): align history table styling with main keys table

wrap history table in table-container div and update css to match
main table: header background, font size, padding, hover effect.

* docs: add jetbrains http client files for manual api testing

add requests.http with all api endpoints and http-client.env.json
for environment variables. update CLAUDE.md with usage instructions.

* refactor: move history tests to keys_test.go, improve comment

- move TestHandler_HandleKeyHistory, TestHandler_HandleKeyRevision,
  TestHandler_HandleKeyRestore from handler_test.go to keys_test.go
  to follow one-test-file-per-source-file convention
- improve comment in api/handler.go to explain why base64 encoding
  is used (safe JSON transmission) rather than just what it does

211 of 252 new or added lines in 5 files covered. (83.73%)

2572 of 3104 relevant lines covered (82.86%)

84.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.12
/app/git/git.go
1
// Package git provides git-based versioning for key-value storage.
2
// It tracks all changes to keys in a local git repository with optional
3
// push to remote.
4
package git
5

6
import (
7
        "errors"
8
        "fmt"
9
        "os"
10
        "path/filepath"
11
        "strings"
12
        "time"
13

14
        "github.com/go-git/go-git/v5"
15
        "github.com/go-git/go-git/v5/config"
16
        "github.com/go-git/go-git/v5/plumbing"
17
        "github.com/go-git/go-git/v5/plumbing/object"
18
        "github.com/go-git/go-git/v5/plumbing/transport"
19
        "github.com/go-git/go-git/v5/plumbing/transport/ssh"
20
)
21

22
// Author represents the author of a git commit.
23
type Author struct {
24
        Name  string
25
        Email string
26
}
27

28
// DefaultAuthor returns the default author for git commits.
29
func DefaultAuthor() Author {
41✔
30
        return Author{Name: "stash", Email: "stash@localhost"}
41✔
31
}
41✔
32

33
// KeyValue holds a key's value and format metadata.
34
type KeyValue struct {
35
        Value  []byte
36
        Format string
37
}
38

39
// HistoryEntry represents a single revision of a key.
40
type HistoryEntry struct {
41
        Hash      string    `json:"hash"`
42
        Timestamp time.Time `json:"timestamp"`
43
        Author    string    `json:"author"`
44
        Operation string    `json:"operation"`
45
        Format    string    `json:"format"`
46
        Value     []byte    `json:"value"`
47
}
48

49
// CommitRequest holds parameters for a git commit operation.
50
type CommitRequest struct {
51
        Key       string
52
        Value     []byte
53
        Operation string
54
        Format    string
55
        Author    Author
56
}
57

58
// Config holds git repository configuration
59
type Config struct {
60
        Path   string // local repository path
61
        Branch string // branch name (default: master)
62
        Remote string // remote name (optional, for push/pull)
63
        SSHKey string // path to SSH private key (optional, for push)
64
}
65

66
// Store provides git-backed versioning for key-value storage
67
type Store struct {
68
        cfg  Config
69
        repo *git.Repository
70
}
71

72
// New creates a new git store, initializing or opening the repository
73
func New(cfg Config) (*Store, error) {
44✔
74
        if cfg.Path == "" {
45✔
75
                return nil, errors.New("git path is required")
1✔
76
        }
1✔
77
        if cfg.Branch == "" {
78✔
78
                cfg.Branch = "master"
35✔
79
        }
35✔
80

81
        s := &Store{cfg: cfg}
43✔
82
        if err := s.initRepo(); err != nil {
43✔
83
                return nil, fmt.Errorf("failed to init git repo: %w", err)
×
84
        }
×
85
        return s, nil
43✔
86
}
87

88
// initRepo opens existing or creates new git repository
89
func (s *Store) initRepo() error {
43✔
90
        // try to open existing repo
43✔
91
        repo, err := git.PlainOpen(s.cfg.Path)
43✔
92
        if err == nil {
46✔
93
                s.repo = repo
3✔
94
                return s.ensureBranch()
3✔
95
        }
3✔
96

97
        // create new repo if not exists
98
        if errors.Is(err, git.ErrRepositoryNotExists) {
80✔
99
                return s.createNewRepo()
40✔
100
        }
40✔
101

102
        return fmt.Errorf("failed to open repo: %w", err)
×
103
}
104

105
// ensureBranch checks out the configured branch, creating it if necessary
106
func (s *Store) ensureBranch() error {
3✔
107
        wt, err := s.repo.Worktree()
3✔
108
        if err != nil {
3✔
109
                return fmt.Errorf("failed to get worktree: %w", err)
×
110
        }
×
111

112
        branchRef := plumbing.NewBranchReferenceName(s.cfg.Branch)
3✔
113

3✔
114
        // try to checkout existing branch
3✔
115
        if chkErr := wt.Checkout(&git.CheckoutOptions{Branch: branchRef}); chkErr == nil {
4✔
116
                return nil
1✔
117
        }
1✔
118

119
        // branch doesn't exist, create it from HEAD
120
        head, headErr := s.repo.Head()
2✔
121
        if headErr != nil {
2✔
122
                return fmt.Errorf("failed to get HEAD: %w", headErr)
×
123
        }
×
124

125
        // create and checkout the branch
126
        if chkErr := wt.Checkout(&git.CheckoutOptions{Branch: branchRef, Hash: head.Hash(), Create: true}); chkErr != nil {
2✔
127
                return fmt.Errorf("failed to checkout branch %s: %w", s.cfg.Branch, chkErr)
×
128
        }
×
129
        return nil
2✔
130
}
131

132
func (s *Store) createNewRepo() error {
40✔
133
        repo, err := git.PlainInit(s.cfg.Path, false)
40✔
134
        if err != nil {
40✔
135
                return fmt.Errorf("failed to init repo: %w", err)
×
136
        }
×
137
        s.repo = repo
40✔
138

40✔
139
        // create initial commit on configured branch
40✔
140
        wt, wtErr := repo.Worktree()
40✔
141
        if wtErr != nil {
40✔
142
                return fmt.Errorf("failed to get worktree: %w", wtErr)
×
143
        }
×
144

145
        // create .gitkeep to have something to commit
146
        gitkeep := filepath.Join(s.cfg.Path, ".gitkeep")
40✔
147
        if writeErr := os.WriteFile(gitkeep, []byte{}, 0o600); writeErr != nil {
40✔
148
                return fmt.Errorf("failed to create .gitkeep: %w", writeErr)
×
149
        }
×
150
        if _, addErr := wt.Add(".gitkeep"); addErr != nil {
40✔
151
                return fmt.Errorf("failed to stage .gitkeep: %w", addErr)
×
152
        }
×
153

154
        _, commitErr := wt.Commit("initial commit", &git.CommitOptions{
40✔
155
                Author: &object.Signature{
40✔
156
                        Name:  "stash",
40✔
157
                        Email: "stash@localhost",
40✔
158
                        When:  time.Now(),
40✔
159
                },
40✔
160
        })
40✔
161
        if commitErr != nil {
40✔
162
                return fmt.Errorf("failed to create initial commit: %w", commitErr)
×
163
        }
×
164

165
        // checkout configured branch (create if not master)
166
        if s.cfg.Branch != "master" {
41✔
167
                head, headErr := repo.Head()
1✔
168
                if headErr != nil {
1✔
169
                        return fmt.Errorf("failed to get HEAD: %w", headErr)
×
170
                }
×
171
                branchRef := plumbing.NewBranchReferenceName(s.cfg.Branch)
1✔
172
                if chkErr := wt.Checkout(&git.CheckoutOptions{
1✔
173
                        Branch: branchRef,
1✔
174
                        Hash:   head.Hash(),
1✔
175
                        Create: true,
1✔
176
                }); chkErr != nil {
1✔
177
                        return fmt.Errorf("failed to checkout branch %s: %w", s.cfg.Branch, chkErr)
×
178
                }
×
179
        }
180

181
        return nil
40✔
182
}
183

184
// Commit writes key-value to file and commits to git.
185
func (s *Store) Commit(req CommitRequest) error {
50✔
186
        // validate key before any file operations
50✔
187
        if err := s.validateKey(req.Key); err != nil {
56✔
188
                return err
6✔
189
        }
6✔
190

191
        // default format to text
192
        format := req.Format
44✔
193
        if format == "" {
78✔
194
                format = "text"
34✔
195
        }
34✔
196

197
        // convert key to file path with .val suffix
198
        filePath := keyToPath(req.Key)
44✔
199
        fullPath := filepath.Join(s.cfg.Path, filePath)
44✔
200

44✔
201
        // ensure parent directory exists
44✔
202
        if err := os.MkdirAll(filepath.Dir(fullPath), 0o750); err != nil {
44✔
203
                return fmt.Errorf("failed to create directory: %w", err)
×
204
        }
×
205

206
        // write file
207
        if err := os.WriteFile(fullPath, req.Value, 0o600); err != nil {
44✔
208
                return fmt.Errorf("failed to write file: %w", err)
×
209
        }
×
210

211
        // stage file
212
        wt, err := s.repo.Worktree()
44✔
213
        if err != nil {
44✔
214
                return fmt.Errorf("failed to get worktree: %w", err)
×
215
        }
×
216

217
        if _, addErr := wt.Add(filePath); addErr != nil {
44✔
218
                return fmt.Errorf("failed to stage file: %w", addErr)
×
219
        }
×
220

221
        // commit with metadata including format
222
        msg := fmt.Sprintf("%s %s\n\ntimestamp: %s\noperation: %s\nkey: %s\nformat: %s",
44✔
223
                req.Operation, req.Key, time.Now().Format(time.RFC3339), req.Operation, req.Key, format)
44✔
224

44✔
225
        _, commitErr := wt.Commit(msg, &git.CommitOptions{
44✔
226
                Author: &object.Signature{
44✔
227
                        Name:  req.Author.Name,
44✔
228
                        Email: req.Author.Email,
44✔
229
                        When:  time.Now(),
44✔
230
                },
44✔
231
        })
44✔
232
        if commitErr != nil {
44✔
233
                return fmt.Errorf("failed to commit: %w", commitErr)
×
234
        }
×
235

236
        return nil
44✔
237
}
238

239
// Delete removes key file and commits the deletion.
240
// The author parameter specifies who made the change.
241
func (s *Store) Delete(key string, author Author) error {
6✔
242
        // validate key before any file operations
6✔
243
        if err := s.validateKey(key); err != nil {
10✔
244
                return err
4✔
245
        }
4✔
246

247
        filePath := keyToPath(key)
2✔
248
        fullPath := filepath.Join(s.cfg.Path, filePath)
2✔
249

2✔
250
        // check if file exists
2✔
251
        if _, err := os.Stat(fullPath); os.IsNotExist(err) {
3✔
252
                return nil // nothing to delete
1✔
253
        }
1✔
254

255
        // remove file
256
        if err := os.Remove(fullPath); err != nil {
1✔
257
                return fmt.Errorf("failed to remove file: %w", err)
×
258
        }
×
259

260
        // stage deletion
261
        wt, err := s.repo.Worktree()
1✔
262
        if err != nil {
1✔
263
                return fmt.Errorf("failed to get worktree: %w", err)
×
264
        }
×
265

266
        if _, rmErr := wt.Remove(filePath); rmErr != nil {
1✔
267
                return fmt.Errorf("failed to stage deletion: %w", rmErr)
×
268
        }
×
269

270
        // commit deletion
271
        msg := fmt.Sprintf("delete %s\n\ntimestamp: %s\noperation: delete\nkey: %s", key, time.Now().Format(time.RFC3339), key)
1✔
272
        _, commitErr := wt.Commit(msg, &git.CommitOptions{
1✔
273
                Author: &object.Signature{
1✔
274
                        Name:  author.Name,
1✔
275
                        Email: author.Email,
1✔
276
                        When:  time.Now(),
1✔
277
                },
1✔
278
        })
1✔
279
        if commitErr != nil {
1✔
280
                return fmt.Errorf("failed to commit deletion: %w", commitErr)
×
281
        }
×
282

283
        return nil
1✔
284
}
285

286
// Push pushes commits to remote repository
287
func (s *Store) Push() error {
3✔
288
        if s.cfg.Remote == "" {
5✔
289
                return nil // no remote configured
2✔
290
        }
2✔
291

292
        var auth transport.AuthMethod
1✔
293
        if s.cfg.SSHKey != "" {
2✔
294
                var err error
1✔
295
                auth, err = ssh.NewPublicKeysFromFile("git", s.cfg.SSHKey, "")
1✔
296
                if err != nil {
2✔
297
                        return fmt.Errorf("failed to load SSH key: %w", err)
1✔
298
                }
1✔
299
        }
300

301
        err := s.repo.Push(&git.PushOptions{
×
302
                RemoteName: s.cfg.Remote,
×
303
                Auth:       auth,
×
304
                RefSpecs: []config.RefSpec{
×
305
                        config.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", s.cfg.Branch, s.cfg.Branch)),
×
306
                },
×
307
        })
×
308
        if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
×
309
                return fmt.Errorf("failed to push: %w", err)
×
310
        }
×
311
        return nil
×
312
}
313

314
// Head returns the current HEAD commit hash as a short string
315
func (s *Store) Head() (string, error) {
3✔
316
        ref, err := s.repo.Head()
3✔
317
        if err != nil {
3✔
318
                return "", fmt.Errorf("failed to get HEAD: %w", err)
×
319
        }
×
320
        return ref.Hash().String()[:7], nil
3✔
321
}
322

323
// Pull fetches and merges from remote repository
324
func (s *Store) Pull() error {
3✔
325
        if s.cfg.Remote == "" {
5✔
326
                return nil // no remote configured
2✔
327
        }
2✔
328

329
        var auth transport.AuthMethod
1✔
330
        if s.cfg.SSHKey != "" {
2✔
331
                var err error
1✔
332
                auth, err = ssh.NewPublicKeysFromFile("git", s.cfg.SSHKey, "")
1✔
333
                if err != nil {
2✔
334
                        return fmt.Errorf("failed to load SSH key: %w", err)
1✔
335
                }
1✔
336
        }
337

338
        wt, err := s.repo.Worktree()
×
339
        if err != nil {
×
340
                return fmt.Errorf("failed to get worktree: %w", err)
×
341
        }
×
342

343
        pullErr := wt.Pull(&git.PullOptions{
×
344
                RemoteName:    s.cfg.Remote,
×
345
                Auth:          auth,
×
346
                ReferenceName: plumbing.NewBranchReferenceName(s.cfg.Branch),
×
347
        })
×
348
        if pullErr != nil && !errors.Is(pullErr, git.NoErrAlreadyUpToDate) {
×
349
                return fmt.Errorf("failed to pull: %w", pullErr)
×
350
        }
×
351
        return nil
×
352
}
353

354
// Checkout switches to specified revision (commit, tag, or branch)
355
func (s *Store) Checkout(rev string) error {
4✔
356
        wt, err := s.repo.Worktree()
4✔
357
        if err != nil {
4✔
358
                return fmt.Errorf("failed to get worktree: %w", err)
×
359
        }
×
360

361
        // try to resolve as branch first
362
        branchRef := plumbing.NewBranchReferenceName(rev)
4✔
363
        if _, refErr := s.repo.Reference(branchRef, true); refErr == nil {
5✔
364
                if chkErr := wt.Checkout(&git.CheckoutOptions{Branch: branchRef}); chkErr != nil {
1✔
365
                        return fmt.Errorf("failed to checkout branch %s: %w", rev, chkErr)
×
366
                }
×
367
                return nil
1✔
368
        }
369

370
        // try to resolve as tag
371
        tagRef := plumbing.NewTagReferenceName(rev)
3✔
372
        if _, refErr := s.repo.Reference(tagRef, true); refErr == nil {
4✔
373
                if chkErr := wt.Checkout(&git.CheckoutOptions{Branch: tagRef}); chkErr != nil {
1✔
374
                        return fmt.Errorf("failed to checkout tag %s: %w", rev, chkErr)
×
375
                }
×
376
                return nil
1✔
377
        }
378

379
        // try to resolve as commit hash
380
        hash, resolveErr := s.repo.ResolveRevision(plumbing.Revision(rev))
2✔
381
        if resolveErr != nil {
3✔
382
                return fmt.Errorf("failed to resolve revision %s: %w", rev, resolveErr)
1✔
383
        }
1✔
384

385
        if chkErr := wt.Checkout(&git.CheckoutOptions{Hash: *hash}); chkErr != nil {
1✔
386
                return fmt.Errorf("failed to checkout commit %s: %w", rev, chkErr)
×
387
        }
×
388
        return nil
1✔
389
}
390

391
// ReadAll reads all key-value pairs from the repository with their formats.
392
// Format is extracted from the commit message metadata of the last commit that modified each file.
393
// If no format is found in the commit message, defaults to "text".
394
func (s *Store) ReadAll() (map[string]KeyValue, error) {
7✔
395
        result := make(map[string]KeyValue)
7✔
396

7✔
397
        walkErr := filepath.Walk(s.cfg.Path, func(path string, info os.FileInfo, err error) error {
41✔
398
                if err != nil {
34✔
399
                        return err
×
400
                }
×
401

402
                // skip directories and .git folder
403
                if info.IsDir() {
51✔
404
                        if info.Name() == ".git" {
24✔
405
                                return filepath.SkipDir
7✔
406
                        }
7✔
407
                        return nil
10✔
408
                }
409

410
                // only process .val files
411
                if !strings.HasSuffix(path, ".val") {
24✔
412
                        return nil
7✔
413
                }
7✔
414

415
                // read file content - path is validated by Walk to be within s.cfg.Path
416
                content, readErr := os.ReadFile(path) //nolint:gosec // path is validated by filepath.Walk
10✔
417
                if readErr != nil {
10✔
418
                        return fmt.Errorf("failed to read %s: %w", path, readErr)
×
419
                }
×
420

421
                // convert path back to key
422
                relPath, relErr := filepath.Rel(s.cfg.Path, path)
10✔
423
                if relErr != nil {
10✔
424
                        return fmt.Errorf("failed to get relative path: %w", relErr)
×
425
                }
×
426
                key := pathToKey(relPath)
10✔
427

10✔
428
                // get format from the last commit that modified this file
10✔
429
                format := s.getFileFormat(relPath)
10✔
430

10✔
431
                result[key] = KeyValue{Value: content, Format: format}
10✔
432

10✔
433
                return nil
10✔
434
        })
435

436
        if walkErr != nil {
7✔
437
                return nil, fmt.Errorf("failed to walk repository: %w", walkErr)
×
438
        }
×
439

440
        return result, nil
7✔
441
}
442

443
// History returns commit history for a key (newest first).
444
// limit specifies maximum number of entries to return (0 = unlimited).
445
func (s *Store) History(key string, limit int) ([]HistoryEntry, error) {
4✔
446
        if err := s.validateKey(key); err != nil {
5✔
447
                return nil, err
1✔
448
        }
1✔
449

450
        filePath := keyToPath(key)
3✔
451

3✔
452
        logIter, err := s.repo.Log(&git.LogOptions{
3✔
453
                FileName: &filePath,
3✔
454
        })
3✔
455
        if err != nil {
3✔
NEW
456
                return nil, fmt.Errorf("failed to get log: %w", err)
×
NEW
457
        }
×
458
        defer logIter.Close()
3✔
459

3✔
460
        var entries []HistoryEntry
3✔
461
        count := 0
3✔
462

3✔
463
        for limit <= 0 || count < limit {
11✔
464
                commit, err := logIter.Next()
8✔
465
                if err != nil {
10✔
466
                        break // end of history or error
2✔
467
                }
468

469
                // extract metadata from commit
470
                entry := HistoryEntry{
6✔
471
                        Hash:      commit.Hash.String()[:7],
6✔
472
                        Timestamp: commit.Author.When,
6✔
473
                        Author:    commit.Author.Name,
6✔
474
                        Operation: parseOperationFromCommit(commit.Message),
6✔
475
                        Format:    parseFormatFromCommit(commit.Message),
6✔
476
                }
6✔
477

6✔
478
                // get file content at this commit
6✔
479
                tree, treeErr := commit.Tree()
6✔
480
                if treeErr == nil {
12✔
481
                        file, fileErr := tree.File(filePath)
6✔
482
                        if fileErr == nil {
12✔
483
                                content, contentErr := file.Contents()
6✔
484
                                if contentErr == nil {
12✔
485
                                        entry.Value = []byte(content)
6✔
486
                                }
6✔
487
                        }
488
                }
489

490
                entries = append(entries, entry)
6✔
491
                count++
6✔
492
        }
493

494
        return entries, nil
3✔
495
}
496

497
// GetRevision returns value and format at specific revision.
498
func (s *Store) GetRevision(key, rev string) ([]byte, string, error) {
5✔
499
        if err := s.validateKey(key); err != nil {
6✔
500
                return nil, "", err
1✔
501
        }
1✔
502

503
        filePath := keyToPath(key)
4✔
504

4✔
505
        // resolve revision to commit hash
4✔
506
        hash, err := s.repo.ResolveRevision(plumbing.Revision(rev))
4✔
507
        if err != nil {
5✔
508
                return nil, "", fmt.Errorf("failed to resolve revision %s: %w", rev, err)
1✔
509
        }
1✔
510

511
        // get commit object
512
        commit, err := s.repo.CommitObject(*hash)
3✔
513
        if err != nil {
3✔
NEW
514
                return nil, "", fmt.Errorf("failed to get commit: %w", err)
×
NEW
515
        }
×
516

517
        // get file tree at commit
518
        tree, err := commit.Tree()
3✔
519
        if err != nil {
3✔
NEW
520
                return nil, "", fmt.Errorf("failed to get tree: %w", err)
×
NEW
521
        }
×
522

523
        // get file content
524
        file, err := tree.File(filePath)
3✔
525
        if err != nil {
4✔
526
                return nil, "", fmt.Errorf("file not found at revision %s: %w", rev, err)
1✔
527
        }
1✔
528

529
        content, err := file.Contents()
2✔
530
        if err != nil {
2✔
NEW
531
                return nil, "", fmt.Errorf("failed to read file: %w", err)
×
NEW
532
        }
×
533

534
        // get format from commit message
535
        format := parseFormatFromCommit(commit.Message)
2✔
536

2✔
537
        return []byte(content), format, nil
2✔
538
}
539

540
// getFileFormat finds the last commit that modified a file and extracts format from its message.
541
// returns "text" if no format is found.
542
func (s *Store) getFileFormat(filePath string) string {
10✔
543
        // get log for this specific file
10✔
544
        logIter, err := s.repo.Log(&git.LogOptions{
10✔
545
                FileName: &filePath,
10✔
546
        })
10✔
547
        if err != nil {
10✔
548
                return "text"
×
549
        }
×
550
        defer logIter.Close()
10✔
551

10✔
552
        // get the most recent commit for this file
10✔
553
        commit, err := logIter.Next()
10✔
554
        if err != nil {
10✔
555
                return "text"
×
556
        }
×
557

558
        return parseFormatFromCommit(commit.Message)
10✔
559
}
560

561
// parseFormatFromCommit extracts format value from commit message metadata.
562
// looks for "format: <value>" line in commit message, returns "text" if not found.
563
func parseFormatFromCommit(message string) string {
24✔
564
        for line := range strings.SplitSeq(message, "\n") {
145✔
565
                if format, found := strings.CutPrefix(line, "format: "); found {
141✔
566
                        return format
20✔
567
                }
20✔
568
        }
569
        return "text"
4✔
570
}
571

572
// parseOperationFromCommit extracts operation from commit message metadata.
573
// looks for "operation: <value>" line, or parses first word of commit message.
574
func parseOperationFromCommit(message string) string {
11✔
575
        for line := range strings.SplitSeq(message, "\n") {
48✔
576
                if op, found := strings.CutPrefix(line, "operation: "); found {
45✔
577
                        return op
8✔
578
                }
8✔
579
        }
580
        // fallback: first word of commit message (e.g., "set", "delete")
581
        if parts := strings.Fields(message); len(parts) > 0 {
5✔
582
                return parts[0]
2✔
583
        }
2✔
584
        return "unknown"
1✔
585
}
586

587
// keyToPath converts a key to a file path with .val suffix
588
// e.g., "app/config/db" -> "app/config/db.val"
589
func keyToPath(key string) string {
109✔
590
        return key + ".val"
109✔
591
}
109✔
592

593
// validateKey checks if the key is safe (no path traversal).
594
// returns error if key would escape the repository directory.
595
func (s *Store) validateKey(key string) error {
65✔
596
        // reject empty keys
65✔
597
        if key == "" {
67✔
598
                return errors.New("invalid key: empty key not allowed")
2✔
599
        }
2✔
600

601
        // reject absolute paths
602
        if strings.HasPrefix(key, "/") {
65✔
603
                return errors.New("invalid key: absolute path not allowed")
2✔
604
        }
2✔
605

606
        // reject path traversal sequences
607
        if strings.Contains(key, "..") {
69✔
608
                return errors.New("invalid key: path traversal not allowed")
8✔
609
        }
8✔
610

611
        // double-check: resolved path must be within repo
612
        filePath := filepath.Join(s.cfg.Path, keyToPath(key))
53✔
613
        absPath, err := filepath.Abs(filePath)
53✔
614
        if err != nil {
53✔
615
                return errors.New("invalid key: failed to resolve path")
×
616
        }
×
617
        absBase, err := filepath.Abs(s.cfg.Path)
53✔
618
        if err != nil {
53✔
619
                return errors.New("invalid key: failed to resolve base path")
×
620
        }
×
621

622
        if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) {
53✔
623
                return errors.New("invalid key: path escapes repository")
×
624
        }
×
625

626
        return nil
53✔
627
}
628

629
// pathToKey converts a file path back to a key
630
// e.g., "app/config/db.val" -> "app/config/db"
631
func pathToKey(path string) string {
13✔
632
        return strings.TrimSuffix(path, ".val")
13✔
633
}
13✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc