-
Notifications
You must be signed in to change notification settings - Fork 208
Expand file tree
/
Copy pathlocal.go
More file actions
193 lines (166 loc) · 5.46 KB
/
local.go
File metadata and controls
193 lines (166 loc) · 5.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0
package state
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/stacklok/toolhive-core/httperr"
)
const (
// DefaultAppName is the default application name used for XDG paths
DefaultAppName = "toolhive"
// FileExtension is the file extension for stored configurations
FileExtension = ".json"
)
// LocalStore implements the Store interface using the local filesystem
// following the XDG Base Directory Specification
type LocalStore struct {
// basePath is the base directory path for storing configurations
basePath string
}
// NewLocalStore creates a new LocalStore with the given application name and store type
// If appName is empty, DefaultAppName will be used
func NewLocalStore(appName string, storeName string) (*LocalStore, error) {
if appName == "" {
appName = DefaultAppName
}
// Create the base directory path following XDG spec
basePath := filepath.Join(xdg.StateHome, appName, storeName)
// Ensure the directory exists
if err := os.MkdirAll(basePath, 0750); err != nil {
return nil, fmt.Errorf("failed to create state directory: %w", err)
}
return &LocalStore{
basePath: basePath,
}, nil
}
// getFilePath returns the full file path for a configuration.
// It validates that the resolved path remains within basePath to prevent
// path traversal attacks via crafted names containing ".." or separators.
func (s *LocalStore) getFilePath(name string) (string, error) {
// Ensure the name has the correct extension
if !strings.HasSuffix(name, FileExtension) {
name = name + FileExtension
}
resolved := filepath.Clean(filepath.Join(s.basePath, name))
// Verify the resolved path is contained within basePath.
// The trailing separator prevents prefix collisions (e.g. basePath
// "/state/toolhive" matching "/state/toolhive-evil/foo").
if !strings.HasPrefix(resolved, s.basePath+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid state name %q: path traversal detected", name)
}
return resolved, nil
}
// GetReader returns a reader for the state data
func (s *LocalStore) GetReader(_ context.Context, name string) (io.ReadCloser, error) {
// Open the file
filePath, err := s.getFilePath(name)
if err != nil {
return nil, err
}
// #nosec G304 - filePath is validated by getFilePath to stay within our designated directory
file, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, httperr.WithCode(fmt.Errorf("state '%s' not found", name), http.StatusNotFound)
}
return nil, fmt.Errorf("failed to open state file: %w", err)
}
return file, nil
}
// GetWriter returns a writer for the state data
func (s *LocalStore) GetWriter(_ context.Context, name string) (io.WriteCloser, error) {
// Create the file
filePath, err := s.getFilePath(name)
if err != nil {
return nil, err
}
// #nosec G304 - filePath is validated by getFilePath to stay within our designated directory
file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
return file, nil
}
// CreateExclusive creates a new state entry exclusively, failing if it already exists.
// This provides atomic check-and-create semantics using O_EXCL to prevent race conditions.
func (s *LocalStore) CreateExclusive(_ context.Context, name string) (io.WriteCloser, error) {
filePath, err := s.getFilePath(name)
if err != nil {
return nil, err
}
// O_EXCL with O_CREATE provides atomic check-and-create behavior
// #nosec G304 - filePath is validated by getFilePath to stay within our designated directory
file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
if os.IsExist(err) {
return nil, httperr.WithCode(
fmt.Errorf("state '%s' already exists", name),
http.StatusConflict,
)
}
return nil, fmt.Errorf("failed to create file: %w", err)
}
return file, nil
}
// Delete removes the data for the given name
func (s *LocalStore) Delete(_ context.Context, name string) error {
filePath, err := s.getFilePath(name)
if err != nil {
return err
}
// #nosec G304 - filePath is validated by getFilePath to stay within our designated directory
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("state '%s' not found", name)
}
return fmt.Errorf("failed to delete state file: %w", err)
}
return nil
}
// List returns all available state names
func (s *LocalStore) List(_ context.Context) ([]string, error) {
// Read the directory
entries, err := os.ReadDir(s.basePath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, fmt.Errorf("failed to read state directory: %w", err)
}
// Filter and process the file names
var names []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, FileExtension) {
// Remove the file extension
name = strings.TrimSuffix(name, FileExtension)
names = append(names, name)
}
}
return names, nil
}
// Exists checks if data exists for the given name
func (s *LocalStore) Exists(_ context.Context, name string) (bool, error) {
filePath, err := s.getFilePath(name)
if err != nil {
return false, err
}
_, err = os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("failed to check if state exists: %w", err)
}
return true, nil
}